diff --git a/Cargo.lock b/Cargo.lock index f672690..a70ad06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,8 +39,8 @@ dependencies = [ "encoding_rs", "flate2", "futures-core", - "h2", - "http", + "h2 0.3.24", + "http 0.2.11", "httparse", "httpdate", "itoa", @@ -75,7 +75,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d22475596539443685426b6bdadb926ad0ecaefdfc5fb05e5e3441f15463c511" dependencies = [ "bytestring", - "http", + "http 0.2.11", "regex", "serde", "tracing", @@ -254,6 +254,16 @@ dependencies = [ "libc", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.77" @@ -341,6 +351,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard-stubs" +version = "1.42.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed59b5c00048f48d7af971b71f800fdf23e858844a6f9e4d32ca72e9399e7864" +dependencies = [ + "serde", + "serde_with", +] + [[package]] name = "brotli" version = "3.4.0" @@ -419,6 +439,16 @@ dependencies = [ "windows-targets 0.52.0", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.3", + "stacker", +] + [[package]] name = "claim" version = "0.5.0" @@ -580,6 +610,59 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "deadpool" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63dfa964fe2a66f3fde91fc70b267fe193d822c7e603e2a675a49a7f46ad3f49" + [[package]] name = "der" version = "0.7.8" @@ -655,6 +738,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -756,6 +855,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -765,6 +879,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -809,6 +938,17 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "futures-sink" version = "0.3.30" @@ -827,8 +967,10 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -885,7 +1027,26 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.11", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31d030e59af851932b72ceebadf4a2b5986dba4c3b99dd2493f8273a0f151943" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 1.0.0", "indexmap", "slab", "tokio", @@ -966,6 +1127,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + [[package]] name = "http" version = "0.2.11" @@ -977,6 +1149,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -984,7 +1167,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.11", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.0.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cb79eb393015dadd30fc252023adb0b2400a0caee0fa2a077e6e21a551e840" +dependencies = [ + "bytes", + "futures-util", + "http 1.0.0", + "http-body 1.0.0", "pin-project-lite", ] @@ -1010,9 +1216,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.24", + "http 0.2.11", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -1024,6 +1230,27 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.2", + "http 1.0.0", + "http-body 1.0.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-rustls" version = "0.24.2" @@ -1031,13 +1258,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", - "http", - "hyper", + "http 0.2.11", + "hyper 0.14.28", "rustls", "tokio", "tokio-rustls", ] +[[package]] +name = "hyper-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +dependencies = [ + "bytes", + "futures-util", + "http 1.0.0", + "http-body 1.0.0", + "hyper 1.2.0", + "pin-project-lite", + "socket2", + "tokio", +] + [[package]] name = "iana-time-zone" version = "0.1.60" @@ -1061,6 +1304,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.4.0" @@ -1156,6 +1405,31 @@ dependencies = [ "spin 0.5.2", ] +[[package]] +name = "lettre" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357ff5edb6d8326473a64c82cf41ddf78ab116f89668c50c4fac1b321e5e80f4" +dependencies = [ + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna 0.5.0", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "url", +] + [[package]] name = "libc" version = "0.2.152" @@ -1223,6 +1497,12 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + [[package]] name = "matchers" version = "0.1.0" @@ -1287,6 +1567,24 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d02c0b00610773bb7fc61d85e13d86c7858cbdf00e1a120bfc41bc055dbaa0e" +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.27.1" @@ -1390,6 +1688,50 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae94056a791d0e1217d18b6cbdccb02c61e3054fc69893607f4067e3bb0b1fd1" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-multimap" version = "0.6.0" @@ -1581,6 +1923,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5787f7cda34e3033a72192c018bc5883100330f362ef279a8cbccfce8bb4e874" +dependencies = [ + "cc", +] + [[package]] name = "quickcheck" version = "1.0.3" @@ -1612,6 +1963,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ec282e887b434b68c18fe5c121d38e72a5cf35119b59e54ec5b992ea9c8eb0" + [[package]] name = "rand" version = "0.8.5" @@ -1706,10 +2063,10 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", - "hyper", + "h2 0.3.24", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", "hyper-rustls", "ipnet", "js-sys", @@ -1856,6 +2213,15 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1882,6 +2248,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.21" @@ -1940,6 +2329,28 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" +dependencies = [ + "serde", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2259,6 +2670,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "stacker" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c886bd4480155fd3ef527d45e9ac8dd7118a898a46530b7b94c3e21866259fce" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "winapi", +] + [[package]] name = "stringprep" version = "0.1.4" @@ -2270,6 +2694,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "subtle" version = "2.5.0" @@ -2332,6 +2762,32 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "testcontainers" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d2931d7f521af5bae989f716c3fa43a6af9af7ec7a5e21b59ae40878cec00" +dependencies = [ + "bollard-stubs", + "futures", + "hex", + "hmac", + "log", + "rand", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "testcontainers-modules" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c391cd115649a8a14e5638d0606648d5348b216700a31f402987f57e58693766" +dependencies = [ + "testcontainers", +] + [[package]] name = "thiserror" version = "1.0.57" @@ -3025,6 +3481,30 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wiremock" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec874e1eef0df2dcac546057fe5e29186f09c378181cd7b635b4b7bcc98e9d81" +dependencies = [ + "assert-json-diff", + "async-trait", + "base64", + "deadpool", + "futures", + "http 1.0.0", + "http-body-util", + "hyper 1.2.0", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -3043,6 +3523,7 @@ dependencies = [ "claim", "config", "fake", + "lettre", "once_cell", "quickcheck", "quickcheck_macros", @@ -3050,7 +3531,10 @@ dependencies = [ "reqwest", "secrecy", "serde", + "serde_json", "sqlx", + "testcontainers", + "testcontainers-modules", "tokio", "tracing", "tracing-actix-web", @@ -3060,6 +3544,7 @@ dependencies = [ "unicode-segmentation", "uuid", "validator", + "wiremock", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5f55881..818d852 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ name = "zero2prod" [dependencies] actix-web = "4" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -reqwest = "0.11" +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } config = "0.14" uuid = { version = "1.7.0", features = ["v4"] } @@ -31,12 +31,17 @@ tracing-actix-web = "0.7" unicode-segmentation = "1" claim = "0.5" validator = "0.16" +lettre = "0.11" [dev-dependencies] fake = "2.9" quickcheck = "1.0" quickcheck_macros = "1.0" rand = "0.8" +wiremock = "0.6" +serde_json = "1" +testcontainers = "0.15.0" +testcontainers-modules = { version = "0.3.4", features = ["postgres"] } [dependencies.sqlx] version = "0.7.3" diff --git a/Makefile.toml b/Makefile.toml new file mode 100644 index 0000000..eba5c82 --- /dev/null +++ b/Makefile.toml @@ -0,0 +1,25 @@ +[tasks.format] +install_crate = "rustfmt" +command = "cargo" +args = ["fmt", "--", "--emit=files"] + +[tasks.lint] +install_crate = "rust-clippy" +command = "cargo" +args = ["clippy"] + +[tasks.fmtclip] +dependencies = [ + "format", + "lint" +] + +[tasks.machete] +install_crate = "cargo-machete" +command = "cargo" +args = ["machete"] + +[tasks.audit] +install_crate = "cargo-audit" +command = "cargo" +args = ["audit"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..35472c2 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# Zero2Prod + +I'm going through the ebook "Zero to production in Rust" and this is the code for it! + +## Makefile.toml + +I stumbled over a [Rust tooling article](https://www.shuttle.rs/blog/2024/02/15/best-rust-tooling), therefore I installed, `cargo-make`, `cargo-audit` and other tools. To run the corresponding tool, look at the `Makefiel.toml` and run `cargo make xxx`! + diff --git a/configuration/base.yaml b/configuration/base.yaml index f16313b..c9c6eed 100644 --- a/configuration/base.yaml +++ b/configuration/base.yaml @@ -6,3 +6,8 @@ database: username: "postgres" password: "password" database_name: "newsletter" +email_client: + base_url: "localhost" + sender_email: "test@gmail.com" + authorization_token: "my-secret-token" + timeout_milliseconds: 10000 \ No newline at end of file diff --git a/configuration/production.yaml b/configuration/production.yaml index cd4608a..364a82c 100644 --- a/configuration/production.yaml +++ b/configuration/production.yaml @@ -2,3 +2,6 @@ application: host: 0.0.0.0 database: require_ssl: true +email_client: + base_url: "https://api.postmarkapp.com" + sender_email: "andre@futureblog.eu" \ No newline at end of file diff --git a/src/configuration.rs b/src/configuration.rs index 2e58116..92d7a6b 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -2,10 +2,13 @@ use config::Config; use secrecy::{ExposeSecret, Secret}; use sqlx::{postgres::{PgConnectOptions, PgSslMode}, ConnectOptions}; +use crate::domain::SubscriberEmail; + #[derive(serde::Deserialize)] pub struct Settings { pub database: DatabaseSettings, pub application: ApplicationSettings, + pub email_client: EmailClientSettings, } #[derive(serde::Deserialize)] @@ -45,6 +48,23 @@ impl DatabaseSettings { } } +#[derive(serde::Deserialize)] +pub struct EmailClientSettings { + pub base_url: String, + pub sender_email: String, + pub authorization_token: Secret, + pub timeout_milliseconds: u64, +} + +impl EmailClientSettings { + pub fn sender(&self) -> Result { + SubscriberEmail::parse(self.sender_email.clone()) + } + pub fn timeout(&self) -> std::time::Duration { + std::time::Duration::from_millis(self.timeout_milliseconds) + } +} + pub fn get_configuration() -> Result { let run_mode = std::env::var("APP_ENVIRONMENT").unwrap_or("development".into()); let base_path = std::env::current_dir().expect("Failed to determine the current directory"); diff --git a/src/email_client.rs b/src/email_client.rs index a3c3a00..b340113 100644 --- a/src/email_client.rs +++ b/src/email_client.rs @@ -1,17 +1,197 @@ +use reqwest::Client; +use secrecy::{ExposeSecret, Secret}; use crate::domain::SubscriberEmail; pub struct EmailClient { + http_client: reqwest::Client, + base_url: String, sender: SubscriberEmail, + authorization_token: Secret, } impl EmailClient { + pub fn new( + base_url: String, + sender: SubscriberEmail, + authorization_token: Secret, + timeout: std::time::Duration, + ) -> Self { + let http_client = Client::builder() + .timeout(timeout) + .build() + .expect("Failed to build reqwest client"); + Self { + http_client, + base_url, + sender, + authorization_token, + } + } pub async fn send_email( &self, recipient: SubscriberEmail, subject: &str, html_content: &str, text_content: &str, - ) -> Result<(), String> { - todo!("send_email not implemented") + ) -> Result<(), reqwest::Error> { + let url = format!("{}/email", self.base_url); + let builder = self + .http_client + .post(&url) + .header("X-Postmark-Server-Token", self.authorization_token.expose_secret()) + .json(&SendEmailRequest { + from: self.sender.as_ref(), + to: recipient.as_ref(), + subject, + html_body: html_content, + text_body: text_content, + }) + .send() + .await? + .error_for_status()?; + Ok(()) + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "PascalCase")] +struct SendEmailRequest<'a> { + from: &'a str, + to: &'a str, + subject: &'a str, + html_body: &'a str, + text_body: &'a str, +} + +#[cfg(test)] +mod tests { + use claim::{assert_err, assert_ok}; + use fake::{faker::{internet::en::SafeEmail, lorem::en::{Paragraph, Sentence}}, Fake, Faker}; + use secrecy::Secret; + use wiremock::{http::Method, matchers::{any, header, header_exists, method, path}, Mock, MockServer, Request, ResponseTemplate}; + use crate::domain::SubscriberEmail; + + use super::EmailClient; + + fn subject() -> String { + Sentence(1..2).fake() + } + + fn content() -> String { + Paragraph(1..10).fake() + } + + fn email() -> SubscriberEmail { + SubscriberEmail::parse(SafeEmail().fake()).unwrap() + } + + fn email_client(base_url: String) -> EmailClient { + EmailClient::new( + base_url, + email(), + Secret::new(Faker.fake()), + std::time::Duration::from_millis(100), + ) + } + + #[tokio::test] + async fn send_email_fires_a_request_to_base_url() { + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + Mock::given(header_exists("X-Postmark-Server-Token")) + .and(header("Content-Type", "application/json")) + .and(path("/email")) + .and(method(Method::POST)) + .and(SendEmailBodyMatcher) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + let _ = email_client + .send_email(email(), &subject(), &content(), &content()) + .await; + } + + struct SendEmailBodyMatcher; + + impl wiremock::Match for SendEmailBodyMatcher { + fn matches(&self, request: &Request) -> bool { + let result: Result = serde_json::from_slice(&request.body); + if let Ok(body) = result { + body.get("From").is_some() + && body.get("To").is_some() + && body.get("Subject").is_some() + && body.get("HtmlBody").is_some() + && body.get("TextBody").is_some() + } else { + false + } + } + } + + #[tokio::test] + async fn send_email_succeeds_if_the_server_returns_200() { + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + Mock::given(any()) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&mock_server) + .await; + + let outcome = email_client + .send_email(email(), &subject(), &content(), &content()) + .await; + + assert_ok!(outcome); + } + + #[tokio::test] + async fn send_email_fails_if_the_server_returns_500() { + let mock_server = MockServer::start().await; + let email_client = email_client(mock_server.uri()); + + Mock::given(any()) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&mock_server) + .await; + + let outcome = email_client + .send_email(email(), &subject(), &content(), &content()) + .await; + + assert_err!(outcome); + } + + #[tokio::test] + async fn send_email_times_out_if_the_server_takes_too_long() { + let mock_server = MockServer::start().await; + let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let email_client = EmailClient::new( + mock_server.uri(), + sender, + Secret::new(Faker.fake()), + std::time::Duration::from_millis(100), + ); + + let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); + let subject: String = Sentence(1..2).fake(); + let content: String = Paragraph(1..10).fake(); + + Mock::given(any()) + .respond_with(ResponseTemplate::new(200) + .set_delay(std::time::Duration::from_secs(180))) + .expect(1) + .mount(&mock_server) + .await; + + let outcome = email_client + .send_email(subscriber_email, &subject, &content, &content) + .await; + + assert_err!(outcome); } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index a6bf203..57c0684 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use std::net::TcpListener; use sqlx::postgres::PgPoolOptions; use zero2prod::configuration::get_configuration; +use zero2prod::email_client; use zero2prod::startup::run; use zero2prod::telemetry::{get_subscriber, init_subscriber}; @@ -10,11 +11,20 @@ async fn main() -> std::io::Result<()> { init_subscriber(subscriber); let config = get_configuration().expect("Failed to read configuration"); - println!("Application configuration: {}", config.application.port); + let connection_pool = PgPoolOptions::new().acquire_timeout(std::time::Duration::from_secs(2)) .max_connections(10).connect_lazy_with(config.database.with_db()); + let sender_email = config.email_client.sender().unwrap(); + let timeout = config.email_client.timeout(); + let email_client = email_client::EmailClient::new( + config.email_client.base_url, + sender_email, + config.email_client.authorization_token, + timeout, + ); + let address = format!("{}:{}", config.application.host, config.application.port); let listener = TcpListener::bind(address).expect("Failed to bind random port"); - run(listener, connection_pool)?.await + run(listener, connection_pool ,email_client)?.await } diff --git a/src/routes/subscriptions.rs b/src/routes/subscriptions.rs index 5eefda4..2bebcb3 100644 --- a/src/routes/subscriptions.rs +++ b/src/routes/subscriptions.rs @@ -3,6 +3,9 @@ use serde::Deserialize; use chrono::Utc; use uuid::Uuid; use sqlx::{query, Pool, Postgres}; +use lettre::{ + address::AddressError, message::{header::ContentType, Mailbox}, transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport +}; use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName}; @@ -26,11 +29,64 @@ pub async fn subscribe(form: web::Form, connection_pool: web::Data return HttpResponse::BadRequest().finish(), }; match insert_subscriber(&new_subscriber, &connection_pool).await { + Ok(_) => (), + Err(_) => return HttpResponse::InternalServerError().finish(), + } + match send_mail(&new_subscriber) { Ok(_) => HttpResponse::Ok().finish(), Err(_) => HttpResponse::InternalServerError().finish(), + } } +#[tracing::instrument( + name = "Sending confirmation email", + skip(new_subscriber) +)] +fn send_mail(new_subscriber: &NewSubscriber) -> Result<(), String> { + let from: Mailbox = "Andre Heber ".parse().map_err(|e: AddressError| { + tracing::error!("Could not parse email - from: {:?}", e); + e.to_string() + })?; + + let reply_to: Mailbox = "noreply@futureblog.eu".parse().map_err(|e: AddressError| { + tracing::error!("Could not parse email - reply_to: {:?}", e); + e.to_string() + })?; + + let to: Mailbox = new_subscriber.email.as_ref().parse().map_err(|e: AddressError| { + tracing::error!("Could not parse email - to: {:?}", e); + e.to_string() + })?; + + let email = Message::builder() + .from(from) + .reply_to(reply_to) + .to(to) + .subject("Rust Email") + .header(ContentType::TEXT_PLAIN) + .body(String::from("Hello, this is a test email from Rust!")) + .map_err(|e| { + tracing::error!("Could not build email: {:?}", e); + e.to_string() + })?; + + let creds = Credentials::new("andre@futureblog.eu".to_string(), "d6vanPc4RUeQ".to_string()); + let mailer = SmtpTransport::relay("mail.futureblog.eu") + .map_err(|e| { + tracing::error!("Could not connect to server: {:?}", e); + e.to_string() + })?; + let mailer = mailer.credentials(creds).build(); + + mailer.send(&email) + .map_err(|e| { + tracing::error!("Could not send email: {:?}", e); + e.to_string() + })?; + Ok(()) +} + impl TryFrom for NewSubscriber { type Error = String; diff --git a/src/startup.rs b/src/startup.rs index cc1413a..35bb696 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -4,16 +4,23 @@ use sqlx::{Pool, Postgres}; use tracing_actix_web::TracingLogger; use std::net::TcpListener; +use crate::email_client::EmailClient; use crate::routes::{health_check, subscribe}; -pub fn run(listener: TcpListener, connection_pool: Pool) -> Result { +pub fn run( + listener: TcpListener, + connection_pool: Pool, + email_client: EmailClient, +) -> Result { let connection_pool = web::Data::new(connection_pool); + let email_client = web::Data::new(email_client); let server = HttpServer::new(move || { App::new() .wrap(TracingLogger::default()) .route("/health_check", web::get().to(health_check)) .route("/subscriptions", web::post().to(subscribe)) .app_data(connection_pool.clone()) + .app_data(email_client.clone()) }) .listen(listener)? .run(); diff --git a/tests/health_check.rs b/tests/health_check.rs index 4f5b01a..39a7c83 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -1,8 +1,10 @@ use once_cell::sync::Lazy; +use secrecy::ExposeSecret; use sqlx::{postgres::PgPoolOptions, query, Connection, Executor, PgConnection, Pool, Postgres}; +use testcontainers::{clients::Cli, RunnableImage}; use uuid::Uuid; use std:: net::TcpListener; -use zero2prod::{configuration::{get_configuration, DatabaseSettings}, telemetry::{get_subscriber, init_subscriber}}; +use zero2prod::{configuration::{get_configuration, DatabaseSettings}, email_client, telemetry::{get_subscriber, init_subscriber}}; pub struct TestApp { pub address: String, @@ -22,6 +24,13 @@ static TRACING: Lazy<()> = Lazy::new(|| { } }); +fn create_db(config: &DatabaseSettings) -> RunnableImage { + RunnableImage::from(testcontainers_modules::postgres::Postgres::default()) + .with_env_var(("POSTGRES_PASSWORD", config.password.expose_secret())) + .with_env_var(("POSTGRES_USER", &config.username)) + .with_env_var(("POSTGRES_DB", &config.database_name)) +} + async fn spawn_app() -> TestApp { Lazy::force(&TRACING); @@ -30,10 +39,22 @@ async fn spawn_app() -> TestApp { let address = format!("http://127.0.0.1:{}", port); let mut config = get_configuration().expect("Failed to read configuration"); + let image = create_db(&config.database); + let docker = Cli::default(); + docker.run(image); config.database.database_name = Uuid::new_v4().to_string(); let connection_pool = configure_database(&config.database).await; - let server = zero2prod::startup::run(listener, connection_pool.clone()).expect("Failed to bind address"); + let sender_email = config.email_client.sender().unwrap(); + let timeout = config.email_client.timeout(); + let email_client = email_client::EmailClient::new( + config.email_client.base_url, + sender_email, + config.email_client.authorization_token, + timeout, + ); + + let server = zero2prod::startup::run(listener, connection_pool.clone(), email_client).expect("Failed to bind address"); let _ = tokio::spawn(server); TestApp { @@ -85,7 +106,7 @@ async fn subscribe_returns_a_200_for_valid_form_data() { let app = spawn_app().await; let client = reqwest::Client::new(); - let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; + let body = "name=andre&email=andre.heber@gmx.net"; let response = client .post(&format!("{}/subscriptions", &app.address)) .header("Content-Type", "application/x-www-form-urlencoded") @@ -101,8 +122,8 @@ async fn subscribe_returns_a_200_for_valid_form_data() { .await .expect("Failed to fetch saved subscription."); - assert_eq!(saved.email, "ursula_le_guin@gmail.com"); - assert_eq!(saved.name, "le guin"); + assert_eq!(saved.email, "andre.heber@gmx.net"); + assert_eq!(saved.name, "andre"); } #[tokio::test]