confirmation emails (hacky)
This commit is contained in:
parent
486fe68d3b
commit
ca21a84725
10 changed files with 334 additions and 44 deletions
150
Cargo.lock
generated
150
Cargo.lock
generated
|
@ -148,6 +148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
|
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
|
"axum-macros",
|
||||||
"bytes",
|
"bytes",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
@ -219,6 +220,17 @@ dependencies = [
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-macros"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backtrace"
|
name = "backtrace"
|
||||||
version = "0.3.75"
|
version = "0.3.75"
|
||||||
|
@ -387,6 +399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -452,7 +465,7 @@ dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"subtle",
|
"subtle",
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
@ -544,7 +557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -695,6 +708,8 @@ dependencies = [
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"parking",
|
"parking",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1475,6 +1490,20 @@ version = "0.4.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maik"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba8f0b47dc7cc74760332828e060ec705339b5e863894f575d81691546bbc836"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"lazy_static",
|
||||||
|
"native-tls",
|
||||||
|
"rcgen",
|
||||||
|
"regex",
|
||||||
|
"wg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
@ -1580,7 +1609,7 @@ dependencies = [
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
@ -1737,6 +1766,16 @@ version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
@ -1868,6 +1907,21 @@ dependencies = [
|
||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic-util"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
|
@ -1954,8 +2008,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1965,7 +2029,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1977,6 +2051,28 @@ dependencies = [
|
||||||
"getrandom 0.2.16",
|
"getrandom 0.2.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rcgen"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||||
|
dependencies = [
|
||||||
|
"pem",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"time",
|
||||||
|
"yasna",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.12"
|
version = "0.5.12"
|
||||||
|
@ -2115,7 +2211,7 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
@ -2388,7 +2484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -2556,7 +2652,7 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
@ -2596,7 +2692,7 @@ dependencies = [
|
||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
@ -3082,6 +3178,16 @@ version = "0.1.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc"
|
checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "triomphe"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "try-lock"
|
name = "try-lock"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
@ -3314,6 +3420,18 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wg"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7aafc5e81e847f05d6770e074faf7b1cd4a5dec9a0e88eac5d55e20fdfebee9a"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"futures-core",
|
||||||
|
"parking_lot",
|
||||||
|
"triomphe",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
@ -3682,6 +3800,15 @@ dependencies = [
|
||||||
"hashlink",
|
"hashlink",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yasna"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||||
|
dependencies = [
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
@ -3719,7 +3846,10 @@ dependencies = [
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"lettre",
|
"lettre",
|
||||||
|
"maik",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
|
"rand 0.9.1",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
@ -9,13 +9,14 @@ license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { version = "1.0.71", features = ["backtrace"] }
|
anyhow = { version = "1.0.71", features = ["backtrace"] }
|
||||||
axum = {version = "0.8", features = ["tokio", "http1", "http2"] }
|
axum = { version = "0.8", features = ["tokio", "http1", "http2", "macros"] }
|
||||||
axum-extra = { version = "0.10", features = ["cookie-private", "typed-header"] }
|
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"] }
|
lettre = { version = "0.11.17", features = ["tokio1", "tokio1-native-tls", "tracing", "web"] }
|
||||||
pin-project = "1.1.0"
|
pin-project = "1.1.0"
|
||||||
|
rand = "0.9.1"
|
||||||
serde = { version = "1.0.164", features = ["derive"] }
|
serde = { version = "1.0.164", features = ["derive"] }
|
||||||
serde_json = "1.0.99"
|
serde_json = "1.0.99"
|
||||||
sqlx = { version = "0.8.5", features = ["runtime-tokio", "macros", "postgres", "uuid", "chrono", "migrate"] }
|
sqlx = { version = "0.8.5", features = ["runtime-tokio", "macros", "postgres", "uuid", "chrono", "migrate"] }
|
||||||
|
@ -33,6 +34,8 @@ uuid = { version = "1.16.0", features = ["v4"] }
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
bollard = { git = "https://github.com/fussybeaver/bollard.git", rev = "50a25a0" }
|
bollard = { git = "https://github.com/fussybeaver/bollard.git", rev = "50a25a0" }
|
||||||
http-body-util = "0.1.3"
|
http-body-util = "0.1.3"
|
||||||
|
maik = "0.2.0"
|
||||||
|
regex = "1.11.1"
|
||||||
reqwest = { version = "0.12", features = ["cookies"] }
|
reqwest = { version = "0.12", features = ["cookies"] }
|
||||||
test-log = { version = "0.2.12", default-features = false, features = ["trace"] }
|
test-log = { version = "0.2.12", default-features = false, features = ["trace"] }
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET status = 'confirmed'
|
||||||
|
where status IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
|
@ -0,0 +1,6 @@
|
||||||
|
CREATE TABLE subscription_tokens(
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
subscriber uuid NOT NULL
|
||||||
|
REFERENCES subscriptions (id),
|
||||||
|
PRIMARY KEY (token)
|
||||||
|
);
|
|
@ -16,9 +16,11 @@ pub struct App {
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Email {
|
pub struct Email {
|
||||||
pub server: String,
|
pub server: String,
|
||||||
|
pub port: Option<u16>,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub sender: String,
|
pub sender: String,
|
||||||
|
pub cert: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
|
|
@ -3,7 +3,10 @@ use std::time::Duration;
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use lettre::{
|
use lettre::{
|
||||||
message::{header::ContentType, Mailbox},
|
message::{header::ContentType, Mailbox},
|
||||||
transport::smtp::authentication::Credentials,
|
transport::smtp::{
|
||||||
|
authentication::Credentials,
|
||||||
|
client::{Certificate, TlsParametersBuilder},
|
||||||
|
},
|
||||||
AsyncSmtpTransport, AsyncTransport as _, Message, Tokio1Executor,
|
AsyncSmtpTransport, AsyncTransport as _, Message, Tokio1Executor,
|
||||||
};
|
};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
@ -20,21 +23,38 @@ pub(crate) enum EmailClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EmailClient {
|
impl EmailClient {
|
||||||
pub fn new(conf: Option<conf::Email>) -> EmailClient {
|
pub fn new(conf: Option<conf::Email>) -> Result<EmailClient> {
|
||||||
let Some(conf) = conf else {
|
let Some(conf) = conf else {
|
||||||
return EmailClient::Disabled;
|
return Ok(EmailClient::Disabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
let inner: AsyncSmtpTransport<Tokio1Executor> =
|
let mut inner = AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server).unwrap();
|
||||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server)
|
|
||||||
.unwrap()
|
if let Some(port) = conf.port {
|
||||||
|
inner = inner.port(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref cert) = conf.cert {
|
||||||
|
let cert =
|
||||||
|
Certificate::from_pem(cert.as_bytes()).context("parse certificate as PEM")?;
|
||||||
|
let tls_param = TlsParametersBuilder::new(conf.server.clone())
|
||||||
|
.add_root_certificate(cert)
|
||||||
|
.build()
|
||||||
|
.context("build tls params")?;
|
||||||
|
|
||||||
|
inner = inner.tls(lettre::transport::smtp::client::Tls::Opportunistic(
|
||||||
|
tls_param,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner: AsyncSmtpTransport<Tokio1Executor> = inner
|
||||||
.credentials(Credentials::new(
|
.credentials(Credentials::new(
|
||||||
conf.username.clone(),
|
conf.username.clone(),
|
||||||
conf.password.clone(),
|
conf.password.clone(),
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
EmailClient::Enabled { inner, conf }
|
Ok(EmailClient::Enabled { inner, conf })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_email(&self, to: Mailbox, subject: String, body: String) -> Result<()> {
|
pub async fn send_email(&self, to: Mailbox, subject: String, body: String) -> Result<()> {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context as _, Result};
|
||||||
use axum::extract::FromRef;
|
use axum::extract::FromRef;
|
||||||
use axum_extra::extract::cookie::Key;
|
use axum_extra::extract::cookie::Key;
|
||||||
use pin_project::pin_project;
|
use pin_project::pin_project;
|
||||||
|
@ -47,7 +47,8 @@ impl ZeroToAxum {
|
||||||
.connect(&conf.database.url)
|
.connect(&conf.database.url)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let email_client = email_client::EmailClient::new(conf.email.clone());
|
let email_client =
|
||||||
|
email_client::EmailClient::new(conf.email.clone()).context("build email client")?;
|
||||||
|
|
||||||
let app_state = AppState {
|
let app_state = AppState {
|
||||||
conf: Arc::new(conf.clone()),
|
conf: Arc::new(conf.clone()),
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Form, Router};
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{get, post},
|
||||||
|
Form, Router,
|
||||||
|
};
|
||||||
|
use rand::{distr::Alphanumeric, rng, Rng as _};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::types::chrono::Utc;
|
use sqlx::types::chrono::Utc;
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
|
@ -8,7 +15,9 @@ use uuid::Uuid;
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
pub fn build() -> Router<AppState> {
|
pub fn build() -> Router<AppState> {
|
||||||
Router::new().route("/", post(subscribe))
|
Router::new()
|
||||||
|
.route("/", post(subscribe))
|
||||||
|
.route("/confirm/", get(confirm))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
@ -29,31 +38,56 @@ pub async fn subscribe(
|
||||||
return Err(SubscribeError::InvalidEmail);
|
return Err(SubscribeError::InvalidEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug!("email sent");
|
||||||
|
|
||||||
|
let mut txn = db.begin().await.context("start database transaction")?;
|
||||||
|
|
||||||
|
let subscriber_id = Uuid::new_v4();
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO subscriptions (id, email, name, subscribed_at, status)
|
||||||
|
VALUES ($1, $2, $3, $4, 'pending_confirmation')
|
||||||
|
"#,
|
||||||
|
subscriber_id,
|
||||||
|
form.email,
|
||||||
|
form.name,
|
||||||
|
Utc::now()
|
||||||
|
)
|
||||||
|
.execute(&mut *txn)
|
||||||
|
.await
|
||||||
|
.context("insert subscription into database")?;
|
||||||
|
|
||||||
|
let token: String = {
|
||||||
|
let mut rng = rng();
|
||||||
|
std::iter::repeat_with(|| rng.sample(Alphanumeric))
|
||||||
|
.map(char::from)
|
||||||
|
.take(25)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO subscription_tokens (subscriber, token)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
"#,
|
||||||
|
subscriber_id,
|
||||||
|
token
|
||||||
|
)
|
||||||
|
.execute(&mut *txn)
|
||||||
|
.await
|
||||||
|
.context("insert subscription token into database")?;
|
||||||
|
|
||||||
email_client
|
email_client
|
||||||
.send_email(
|
.send_email(
|
||||||
form.email
|
form.email
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| SubscribeError::InvalidEmail)?,
|
.map_err(|_| SubscribeError::InvalidEmail)?,
|
||||||
"Test".to_owned(),
|
"Test".to_owned(),
|
||||||
"This was a test.".to_owned(),
|
format!("Please confirm your subscription: https://example.com/subscriptions/confirm/?token={token}"),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
debug!("email sent");
|
txn.commit().await.context("commit transaction")?;
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO subscriptions (id, email, name, subscribed_at, status)
|
|
||||||
VALUES ($1, $2, $3, $4, 'confirmed')
|
|
||||||
"#,
|
|
||||||
Uuid::new_v4(),
|
|
||||||
form.email,
|
|
||||||
form.name,
|
|
||||||
Utc::now()
|
|
||||||
)
|
|
||||||
.execute(&db)
|
|
||||||
.await
|
|
||||||
.context("insert subscription into database")?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -78,3 +112,57 @@ impl IntoResponse for SubscribeError {
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ConfirmQuery {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn confirm(
|
||||||
|
State(AppState { db, .. }): State<AppState>,
|
||||||
|
Query(query): Query<ConfirmQuery>,
|
||||||
|
) -> Result<(), ConfirmError> {
|
||||||
|
info!(query.token, "confirm attempt");
|
||||||
|
|
||||||
|
if query.token.is_empty() {
|
||||||
|
return Err(ConfirmError::InvalidToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
UPDATE subscriptions SET status = 'confirmed' FROM subscription_tokens
|
||||||
|
WHERE status = 'pending_confirmation' AND subscription_tokens.subscriber = id AND subscription_tokens.token = $1;
|
||||||
|
"#,
|
||||||
|
query.token
|
||||||
|
)
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
.context("insert subscription into database")?.rows_affected();
|
||||||
|
|
||||||
|
if rows < 1 {
|
||||||
|
return Err(ConfirmError::InvalidToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum ConfirmError {
|
||||||
|
#[error("Invalid Confirmation Token")]
|
||||||
|
InvalidToken,
|
||||||
|
#[error("Unknown Error: {0}")]
|
||||||
|
Unknown(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ConfirmError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
match self {
|
||||||
|
ConfirmError::InvalidToken => (StatusCode::BAD_REQUEST, "Invalid Confirmation Token"),
|
||||||
|
ConfirmError::Unknown(e) => {
|
||||||
|
error!(?e, "returning INTERNAL SERVER ERROR");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ pub struct TestServer {
|
||||||
server_task_handle: JoinHandle<()>,
|
server_task_handle: JoinHandle<()>,
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
db: Arc<TestDb>,
|
db: Arc<TestDb>,
|
||||||
|
pub mock_smtp_server: maik::MockServer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestServer {
|
impl TestServer {
|
||||||
|
@ -42,13 +43,26 @@ impl TestServer {
|
||||||
let db = get_shared_db().await;
|
let db = get_shared_db().await;
|
||||||
let url = dbg!(db.get_url());
|
let url = dbg!(db.get_url());
|
||||||
|
|
||||||
|
let mock_smtp_server = maik::MockServer::builder()
|
||||||
|
.add_mailbox("bot@example.com", "1234")
|
||||||
|
.assert_after_n_emails(1)
|
||||||
|
.build();
|
||||||
|
mock_smtp_server.start();
|
||||||
|
|
||||||
let server = ZeroToAxum::serve(Conf {
|
let server = ZeroToAxum::serve(Conf {
|
||||||
app: conf::App {
|
app: conf::App {
|
||||||
listen: "[::]:0".parse().unwrap(),
|
listen: "[::]:0".parse().unwrap(),
|
||||||
},
|
},
|
||||||
database: conf::Database { url },
|
database: conf::Database { url },
|
||||||
debug: true,
|
debug: true,
|
||||||
email: None,
|
email: Some(conf::Email {
|
||||||
|
server: mock_smtp_server.host().to_string(),
|
||||||
|
port: Some(mock_smtp_server.port()),
|
||||||
|
username: "bot@example.com".to_owned(),
|
||||||
|
password: "1234".to_owned(),
|
||||||
|
sender: "bot@example.com".to_owned(),
|
||||||
|
cert: Some(String::from_utf8_lossy(mock_smtp_server.cert_pem()).to_string()),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
@ -60,6 +74,7 @@ impl TestServer {
|
||||||
server_task_handle,
|
server_task_handle,
|
||||||
addr,
|
addr,
|
||||||
db,
|
db,
|
||||||
|
mock_smtp_server,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,6 +83,13 @@ impl TestServer {
|
||||||
format!("http://{}{path}", self.addr)
|
format!("http://{}{path}", self.addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request a graceful shutdown and return imideately.
|
||||||
|
pub async fn start_shutdown(&self) -> Result<()> {
|
||||||
|
self.server_task_handle.abort();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Request a graceful shutdown and then wait for shutdown to complete
|
/// Request a graceful shutdown and then wait for shutdown to complete
|
||||||
pub async fn shutdown(self) -> Result<()> {
|
pub async fn shutdown(self) -> Result<()> {
|
||||||
self.server_task_handle.abort();
|
self.server_task_handle.abort();
|
||||||
|
|
|
@ -2,6 +2,8 @@ pub mod fixture;
|
||||||
use fixture::TestServer;
|
use fixture::TestServer;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use maik::MailAssertion;
|
||||||
|
use regex::bytes::Regex;
|
||||||
use test_log::test as traced;
|
use test_log::test as traced;
|
||||||
|
|
||||||
#[traced(tokio::test)]
|
#[traced(tokio::test)]
|
||||||
|
@ -19,6 +21,13 @@ async fn subscribe_succeeds_with_valid_input() -> Result<()> {
|
||||||
|
|
||||||
assert_eq!(resp.status(), 200, "subscribe succeeds");
|
assert_eq!(resp.status(), 200, "subscribe succeeds");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
server
|
||||||
|
.mock_smtp_server
|
||||||
|
.assert(MailAssertion::new().body_matches(Regex::new(r"example\.com")?)),
|
||||||
|
"email sent has link"
|
||||||
|
);
|
||||||
|
|
||||||
server.shutdown().await
|
server.shutdown().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue