diff --git a/.cargo/config.toml b/.cargo/config.toml index 4a6a1abd..fe6eda1c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,8 @@ [resolver] incompatible-rust-versions = "fallback" + +# TODO: Safe to remove once https://github.com/rust-lang/rust/issues/141626 gets resolved. +# Also, see https://github.com/cot-rs/cot/pull/419/changes#r2636869773 for more info. +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" +rustflags = ["-C", "symbol-mangling-version=v0"] diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f4787bec..c04a7557 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -15,6 +15,10 @@ env: SCCACHE_GHA_ENABLED: true RUSTC_WRAPPER: sccache + _RUST_STABLE: &rust_stable stable + # Pinning the nightly version to a "stable" version to avoid CI breakages. + _RUST_NIGHTLY: &rust_nightly nightly-2025-11-11 + # See: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#concurrency. # This will ensure that only one commit will be running tests at a time on each PR. concurrency: @@ -58,9 +62,9 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] include: - rust: stable - version: stable + version: *rust_stable - rust: nightly - version: nightly + version: *rust_nightly - rust: MSRV version: "1.88" # MSRV @@ -149,7 +153,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # cot_macros ui tests require nightly - toolchain: nightly + toolchain: *rust_nightly - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -195,7 +199,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # branch coverage is currently optional and requires nightly - toolchain: nightly + toolchain: *rust_nightly components: llvm-tools-preview - name: Reclaim disk space @@ -248,7 +252,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # nightly-only rustfmt settings - toolchain: nightly + toolchain: *rust_nightly components: rustfmt - name: Cache Cargo registry @@ -299,7 +303,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # the `-Z` flag is only accepted on the nightly channel of Cargo - toolchain: nightly + toolchain: *rust_nightly - name: Cache Cargo registry uses: Swatinem/rust-cache@v2 @@ -368,7 +372,7 @@ jobs: uses: dtolnay/rust-toolchain@master with: # miri requires nightly - toolchain: nightly + toolchain: *rust_nightly components: miri - name: Cache Cargo registry diff --git a/Cargo.lock b/Cargo.lock index 8001be5e..a4b6edb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object 0.32.2", +] + [[package]] name = "argon2" version = "0.5.3" @@ -218,6 +227,150 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -240,6 +393,12 @@ dependencies = [ "syn", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -302,9 +461,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -328,7 +487,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link", ] @@ -381,6 +540,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bstr" version = "1.12.1" @@ -428,14 +600,20 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.50" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -466,6 +644,16 @@ dependencies = [ "phf 0.12.1", ] +[[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 = "ciborium" version = "0.2.2" @@ -656,6 +844,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -685,6 +883,7 @@ dependencies = [ "bytes", "chrono", "chrono-tz", + "chumsky", "clap", "cot_macros", "criterion", @@ -706,7 +905,9 @@ dependencies = [ "http-body", "http-body-util", "humantime", + "idna", "indexmap", + "lettre", "mime", "mime_guess", "mockall", @@ -1170,6 +1371,16 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + [[package]] name = "email_address" version = "0.2.9" @@ -1221,6 +1432,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.1" @@ -1232,6 +1449,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "example-admin" version = "0.1.0" @@ -1283,6 +1510,15 @@ dependencies = [ "serde", ] +[[package]] +name = "example-send-email" +version = "0.1.0" +dependencies = [ + "askama", + "cot", + "serde", +] + [[package]] name = "example-sessions" version = "0.1.0" @@ -1344,9 +1580,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "fixedbitset" @@ -1475,6 +1711,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1562,6 +1811,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "grass" version = "0.13.4" @@ -2063,9 +2324,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -2088,9 +2349,31 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" @@ -2102,6 +2385,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lasso" version = "0.7.3" @@ -2120,6 +2412,35 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-std", + "async-trait", + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "rustls-platform-verifier", + "socket2", + "tokio", + "tokio-rustls", + "url", +] + [[package]] name = "libc" version = "0.2.178" @@ -2134,13 +2455,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags", "libc", - "redox_syscall 0.6.0", + "redox_syscall 0.7.0", ] [[package]] @@ -2187,6 +2508,9 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] name = "matchers" @@ -2307,14 +2631,23 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "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.50.3" @@ -2396,6 +2729,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.37.3" @@ -2455,6 +2797,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -2627,6 +2975,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -2682,6 +3041,20 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2761,6 +3134,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.42" @@ -2770,6 +3153,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.3.0" @@ -2887,9 +3276,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ "bitflags", ] @@ -2974,6 +3363,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roff" version = "0.2.2" @@ -3034,6 +3437,80 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.0", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3042,9 +3519,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -3124,7 +3601,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3284,10 +3774,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -3382,7 +3873,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.4.1", "futures-core", "futures-intrusive", "futures-io", @@ -3555,6 +4046,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3824,6 +4328,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -4180,6 +4694,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.7" @@ -4210,6 +4730,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -4359,6 +4885,15 @@ dependencies = [ "url", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -4459,6 +4994,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -4468,6 +5012,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4495,6 +5048,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4543,6 +5111,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4561,6 +5135,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4579,6 +5159,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4609,6 +5195,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4627,6 +5219,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4645,6 +5243,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4663,6 +5267,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index 8d793847..6840f414 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ # Examples "examples/admin", "examples/custom-error-pages", + "examples/send-email", "examples/custom-task", "examples/file-upload", "examples/hello-world", @@ -68,6 +69,7 @@ bytes = "1.11" cargo_toml = "0.22" chrono = { version = "0.4.42", default-features = false } chrono-tz = { version = "0.10.4", default-features = false } +chumsky = { version = "0.9.3", default-features = false } clap = { version = "4.5.53", features = ["deprecated"] } clap-verbosity-flag = { version = "3", default-features = false } clap_complete = "4" @@ -100,6 +102,8 @@ humantime = "2" indexmap = "2" insta = { version = "1", features = ["filters"] } insta-cmd = "0.6" +lettre = { version = "0.11", default-features = false } +idna = { version = "1.1", default-features = false } mime = "0.3" mime_guess = { version = "2", default-features = false } mockall = "0.14" diff --git a/cot-cli/src/new_project.rs b/cot-cli/src/new_project.rs index 1241bc15..a592c6be 100644 --- a/cot-cli/src/new_project.rs +++ b/cot-cli/src/new_project.rs @@ -13,7 +13,7 @@ macro_rules! project_file { }; } -const PROJECT_FILES: [(&str, &str); 10] = [ +const PROJECT_FILES: [(&str, &str); 11] = [ project_file!("Cargo.toml.template"), project_file!("Cargo.lock.template"), project_file!("bacon.toml"), @@ -24,6 +24,7 @@ const PROJECT_FILES: [(&str, &str); 10] = [ project_file!("templates/index.html"), project_file!("config/dev.toml"), project_file!("config/prod.toml.example"), + project_file!(".cargo/config.toml"), ]; #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/cot-cli/src/project_template/.cargo/config.toml b/cot-cli/src/project_template/.cargo/config.toml new file mode 100644 index 00000000..d1eaf1cb --- /dev/null +++ b/cot-cli/src/project_template/.cargo/config.toml @@ -0,0 +1,4 @@ +# TODO: Safe to remove when https://github.com/rust-lang/rust/issues/141626 gets resolved. +# Also, see https://github.com/cot-rs/cot/pull/419/changes#r2636869773 for more info. +[target.'cfg(target_os = "windows")'] +linker = "rust-lld" diff --git a/cot-cli/src/project_template/config/dev.toml b/cot-cli/src/project_template/config/dev.toml index 9e5f0b9a..82213517 100644 --- a/cot-cli/src/project_template/config/dev.toml +++ b/cot-cli/src/project_template/config/dev.toml @@ -18,3 +18,6 @@ secure = false [middlewares.session.store] type = "database" + +[email.transport] +type = "console" diff --git a/cot-cli/src/project_template/config/prod.toml.example b/cot-cli/src/project_template/config/prod.toml.example index 0d692daf..82f3c3fc 100644 --- a/cot-cli/src/project_template/config/prod.toml.example +++ b/cot-cli/src/project_template/config/prod.toml.example @@ -15,3 +15,10 @@ cache_timeout = "1year" [middlewares.session.store] type = "database" + +[email.transport] +type = "console" +# Or: +# type = "smtp" +# url = "smtps://user:password@smtp.gmail.com" +# mechanism = "plain" diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap index 28005faf..69d73a4b 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-5.snap @@ -6,7 +6,7 @@ info: args: - new - "-vvvv" - - /tmp/cot-test-o4uWVf/project + - /tmp/cot-test-s3CCv5/project --- success: true exit_code: 0 @@ -21,6 +21,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap index f1a8e8da..8801f6e3 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project-6.snap @@ -6,7 +6,7 @@ info: args: - new - "-vvvvv" - - /tmp/cot-test-QUOaBC/project + - /tmp/cot-test-KRWMM5/project --- success: true exit_code: 0 @@ -21,6 +21,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap index e9fb34e5..1e588a51 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-5.snap @@ -8,7 +8,7 @@ info: - "--name" - my_project - "-vvvv" - - /tmp/cot-test-BEJYfS/project + - /tmp/cot-test-uCHzuc/project --- success: true exit_code: 0 @@ -23,6 +23,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `my_project` diff --git a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap index 2d703e8f..62f5e7b3 100644 --- a/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap +++ b/cot-cli/tests/snapshot_testing/new/snapshots/cli__snapshot_testing__new__create_new_project_with_custom_name-6.snap @@ -8,7 +8,7 @@ info: - "--name" - my_project - "-vvvvv" - - /tmp/cot-test-IWoQbg/project + - /tmp/cot-test-sFO2nz/project --- success: true exit_code: 0 @@ -23,6 +23,7 @@ TIMESTAMP TRACE cot_cli::new_project: Writing file: "/t TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/templates/index.html" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/dev.toml" TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/config/prod.toml.example" +TIMESTAMP TRACE cot_cli::new_project: Writing file: "/tmp/TEMP_PATH/project/.cargo/config.toml" ----- stderr -----  Creating Cot project `my_project` diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 1cb081b6..e704c97d 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -24,6 +24,7 @@ backtrace.workspace = true bytes.workspace = true chrono = { workspace = true, features = ["alloc", "serde", "clock"] } chrono-tz.workspace = true +chumsky = { workspace = true, optional = true } clap.workspace = true cot_macros.workspace = true deadpool-redis = { workspace = true, features = ["tokio-comp", "rt_tokio_1"], optional = true } @@ -41,7 +42,9 @@ http-body-util.workspace = true http-body.workspace = true http.workspace = true humantime.workspace = true +idna = { workspace = true, optional = true } indexmap.workspace = true +lettre = { workspace = true, features = ["builder", "sendmail-transport", "smtp-transport", "tokio1", "tokio1-rustls", "ring", "rustls-platform-verifier"], optional = true } mime.workspace = true mime_guess.workspace = true multer.workspace = true @@ -97,15 +100,20 @@ ignored = [ # Used indirectly by `grass`, but it doesn't work with the latest versions of Rust if minimal dependency versions # are used "ahash", + # Used by `lettre`, but it causes dependency issues if minimal dependency versions are used + "chumsky", + # Used by `lettre`, but it causes dependency issues if minimal dependency versions are used + "idna", # time requires version 0.3.35 to work with the latest versions of Rust, but we don't use it directly "time", ] [features] default = ["sqlite", "postgres", "mysql", "json"] -full = ["default", "fake", "live-reload", "test", "cache", "redis"] +full = ["default", "fake", "live-reload", "test", "cache", "redis", "email"] fake = ["dep:fake"] db = ["dep:sea-query", "dep:sea-query-binder", "dep:sqlx"] +email = ["dep:lettre", "dep:chumsky", "dep:idna"] sqlite = ["db", "sea-query/backend-sqlite", "sea-query-binder/sqlx-sqlite", "sqlx/sqlite"] postgres = ["db", "sea-query/backend-postgres", "sea-query-binder/sqlx-postgres", "sqlx/postgres"] mysql = ["db", "sea-query/backend-mysql", "sea-query-binder/sqlx-mysql", "sqlx/mysql"] diff --git a/cot/src/config.rs b/cot/src/config.rs index f9d76b83..628321b3 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -25,6 +25,8 @@ use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; use thiserror::Error; +#[cfg(feature = "email")] +use crate::email::transport::smtp::Mechanism; use crate::error::error_impl::impl_into_cot_error; use crate::utils::chrono::DateTimeWithOffsetAdapter; @@ -250,6 +252,25 @@ pub struct ProjectConfig { /// # Ok::<(), cot::Error>(()) /// ``` pub middlewares: MiddlewareConfig, + /// Configuration related to the email backend. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailConfig, ProjectConfig}; + /// + /// let config = ProjectConfig::from_toml( + /// r#" + /// [email.transport] + /// type = "console" + /// "#, + /// )?; + /// + /// assert_eq!(config.email, EmailConfig::default()); + /// # Ok::<(), cot::Error>(()) + /// ``` + #[cfg(feature = "email")] + pub email: EmailConfig, } const fn default_debug() -> bool { @@ -359,6 +380,8 @@ impl ProjectConfigBuilder { cache: self.cache.clone().unwrap_or_default(), static_files: self.static_files.clone().unwrap_or_default(), middlewares: self.middlewares.clone().unwrap_or_default(), + #[cfg(feature = "email")] + email: self.email.clone().unwrap_or_default(), } } } @@ -1802,6 +1825,255 @@ impl Default for SessionMiddlewareConfig { } } +/// The type of email transport backend to use. +/// +/// This specifies what email backend is used for sending emails. +/// The default backend if not specified is `console`. +#[cfg(feature = "email")] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[non_exhaustive] +pub enum EmailTransportTypeConfig { + /// Console email transport backend. + /// + /// This is a convenient transport backend for development and testing that + /// simply prints the email contents to the console instead of actually + /// sending them. + #[default] + Console, + /// SMTP email transport backend. + /// + /// This transport backend sends emails using the Simple Mail Transfer + /// Protocol (SMTP). It requires authentication details and server + /// configuration. + Smtp { + /// The SMTP connection URL. + /// + /// This specifies the protocol, credentials, host, port, and EHLO + /// domain for connecting to the SMTP server. + /// + /// The URL format is: + /// `scheme://user:password@host:port/?ehlo_domain=domain&tls=TLS`. + /// + /// `user`(username) and `password` are optional in the case the + /// server does not require authentication. + /// When `port` is not specified, it is automatically determined based + /// on the `scheme` used. + /// `tls` is used to specify whether STARTTLS should be used for the + /// connection. Supported values for `tls` are: + /// - `required`: Always use STARTTLS. The connection will fail if the + /// server does not support it. + /// - `opportunistic`: Use STARTTLS if the server supports it, otherwise + /// fall back to plain connection. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailTransportTypeConfig, EmailUrl}; + /// use cot::email::transport::smtp::Mechanism; + /// + /// let smtp_config = EmailTransportTypeConfig::Smtp { + /// url: EmailUrl::from("smtps://johndoe:xxxx xxxxx xxxx xxxxx@smtp.gmail.com"), + /// mechanism: Mechanism::Plain, + /// }; + /// ``` + /// + /// # TOML Configuration + /// + /// ```toml + /// [email] + /// type = "smtp" + /// // If email is "johndoe@gmail.com", then the user is "johndoe" + /// url = "smtp://johndoe:xxxx xxxx xxxx xxxx@smtp.gmail.com:587?tls=required" + /// ``` + url: EmailUrl, + /// The authentication mechanism to use. + /// Supported mechanisms are `plain`, `login`, and `xoauth2`. + /// + /// # TOML Configuration + /// + /// ```toml + /// [email.transport] + /// type = "smtp" + /// url = "smtp://johndoe:xxxx xxxx xxxx xxxx@smtp.gmail.com:587?tls=required" + /// mechanism = "plain" # or "login", "xoauth2" + /// ``` + mechanism: Mechanism, + }, +} + +/// Configuration structure for email transport settings. +/// +/// This specifies the email transport backend to use and its associated +/// configuration. +#[cfg(feature = "email")] +#[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[serde(default)] +pub struct EmailTransportConfig { + /// The type of email transport backend to use. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailTransportConfig, EmailTransportTypeConfig}; + /// + /// let config = EmailTransportConfig::builder() + /// .transport_type(EmailTransportTypeConfig::Console) + /// .build(); + /// ``` + #[serde(flatten)] + pub transport_type: EmailTransportTypeConfig, +} + +#[cfg(feature = "email")] +impl EmailTransportConfig { + /// Create a new [`EmailTransportConfigBuilder`] to build a + /// [`EmailTransportConfig`]. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailTransportConfig; + /// + /// let config = EmailTransportConfig::builder().build(); + /// ``` + #[must_use] + pub fn builder() -> EmailTransportConfigBuilder { + EmailTransportConfigBuilder::default() + } +} + +#[cfg(feature = "email")] +impl EmailTransportConfigBuilder { + /// Builds the email transport configuration. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailTransportConfig; + /// + /// let config = EmailTransportConfig::builder().build(); + /// ``` + #[must_use] + pub fn build(&self) -> EmailTransportConfig { + EmailTransportConfig { + transport_type: self.transport_type.clone().unwrap_or_default(), + } + } +} + +/// Configuration for the email system. +/// +/// This specifies all the configuration options for sending emails. +/// +/// # Examples +/// +/// ``` +/// use cot::config::{EmailConfig, EmailTransportConfig, EmailTransportTypeConfig}; +/// +/// let config = EmailConfig::builder() +/// .transport( +/// EmailTransportConfig::builder() +/// .transport_type(EmailTransportTypeConfig::Console) +/// .build(), +/// ) +/// .build(); +/// assert_eq!( +/// config.transport.transport_type, +/// EmailTransportTypeConfig::Console +/// ); +/// ``` +#[cfg(feature = "email")] +#[derive(Debug, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[serde(default)] +pub struct EmailConfig { + /// The type of email transport backend to use. + /// + /// This determines which type of email transport backend to use (`console` + /// or `smtp`) along with its configuration options. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailConfig, EmailTransportConfig, EmailTransportTypeConfig}; + /// + /// let config = EmailConfig::builder() + /// .transport( + /// EmailTransportConfig::builder() + /// .transport_type(EmailTransportTypeConfig::Console) + /// .build(), + /// ) + /// .build(); + /// assert_eq!( + /// config.transport.transport_type, + /// EmailTransportTypeConfig::Console + /// ); + /// ``` + /// + /// # TOML Configuration + /// + /// ```toml + /// [email.transport] + /// type = "console" + /// + /// # Or for SMTP: + /// # [email.transport] + /// # type = "smtp" + /// # auth_id = "your_auth_id" + /// # secret = "your_secret" + /// # mechanism = "plain" # or "login", "xoauth2" + /// # server = "gmail" # or "localhost" + /// ``` + #[builder(default)] + pub transport: EmailTransportConfig, +} + +#[cfg(feature = "email")] +impl EmailConfig { + /// Create a new [`EmailConfigBuilder`] to build an + /// [`EmailConfig`]. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailConfig; + /// + /// let config = EmailConfig::builder().build(); + /// ``` + #[must_use] + pub fn builder() -> EmailConfigBuilder { + EmailConfigBuilder::default() + } +} + +#[cfg(feature = "email")] +impl EmailConfigBuilder { + /// Builds the email configuration. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailConfig; + /// + /// let config = EmailConfig::builder().build(); + /// ``` + #[must_use] + pub fn build(&self) -> EmailConfig { + EmailConfig { + transport: self.transport.clone().unwrap_or_default(), + } + } +} + +#[cfg(feature = "email")] +impl Default for EmailConfig { + fn default() -> Self { + EmailConfig::builder().build() + } +} + /// A secret key. /// /// This is a wrapper over a byte array, which is used to store a cryptographic @@ -2140,6 +2412,54 @@ impl std::fmt::Display for CacheUrl { } } +/// A URL for email services. +/// +/// This is a wrapper over the [`url::Url`] type, which is used to store the +/// URL of an email service. It parses the URL and ensures that it is valid. +/// +/// # Examples +/// +/// ``` +/// use cot::config::EmailUrl; +/// let url = EmailUrl::from("smtp://user:pass@hostname:587"); +/// ``` +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +#[cfg(feature = "email")] +pub struct EmailUrl(url::Url); + +#[cfg(feature = "email")] +impl EmailUrl { + /// Returns the string representation of the email URL. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailUrl; + /// + /// let url = EmailUrl::from("smtp://user:pass@hostname:587"); + /// assert_eq!(url.as_str(), "smtp://user:pass@hostname:587"); + /// ``` + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +#[cfg(feature = "email")] +impl From for EmailUrl { + fn from(url: String) -> Self { + Self(url::Url::parse(&url).expect("valid URL")) + } +} + +#[cfg(feature = "email")] +impl From<&str> for EmailUrl { + fn from(url: &str) -> Self { + Self(url::Url::parse(url).expect("valid URL")) + } +} + #[cfg(test)] mod tests { use time::OffsetDateTime; @@ -2743,4 +3063,59 @@ mod tests { let never = Timeout::Never; assert_eq!(never.canonicalize(), Timeout::Never); } + + #[test] + #[cfg(feature = "email")] + fn email_config_from_toml_console() { + let toml_content = r#" + [email] + type = "console" + "#; + + let config = ProjectConfig::from_toml(toml_content).unwrap(); + + assert_eq!( + config.email.transport.transport_type, + EmailTransportTypeConfig::Console + ); + } + + #[test] + #[cfg(feature = "email")] + fn email_config_from_toml_smtp() { + let toml_content = r#" + [email.transport] + type = "smtp" + url = "smtp://user:pass@hostname:587" + mechanism = "plain" + "#; + let config = ProjectConfig::from_toml(toml_content).unwrap(); + + if let EmailTransportTypeConfig::Smtp { url, mechanism } = + &config.email.transport.transport_type + { + assert_eq!(url.as_str(), "smtp://user:pass@hostname:587"); + assert_eq!(*mechanism, Mechanism::Plain); + } + } + + #[test] + #[cfg(feature = "email")] + fn email_config_builder_defaults() { + let config = EmailConfig::builder().build(); + assert_eq!( + config.transport.transport_type, + EmailTransportTypeConfig::Console + ); + } + + #[test] + #[cfg(feature = "email")] + fn email_url_from_str_and_string() { + let s = "smtp://user:pass@hostname:587"; + let u1 = EmailUrl::from(s); + let u2 = EmailUrl::from(s.to_string()); + assert_eq!(u1, u2); + assert_eq!(u1.as_str(), s); + } } diff --git a/cot/src/email.rs b/cot/src/email.rs new file mode 100644 index 00000000..ffcf0797 --- /dev/null +++ b/cot/src/email.rs @@ -0,0 +1,459 @@ +//! Email sending functionality for Cot. +//! +//! This module exposes a high-level `Email` API that can send +//! [`EmailMessage`] values through a chosen transport backend +//! (see `transport` submodule for available backends). +//! +//! # Examples +//! +//! Send using the console transport backend (prints nicely formatted messages): +//! +//! ``` +//! use cot::common_types::Email; +//! use cot::email::EmailMessage; +//! use cot::email::transport::console::Console; +//! +//! # async fn run() -> cot::Result<()> { +//! let email = cot::email::Email::new(Console::new()); +//! let message = EmailMessage::builder() +//! .from(Email::try_from("no-reply@example.com").unwrap()) +//! .to(vec![Email::try_from("user@example.com").unwrap()]) +//! .subject("Greetings") +//! .body("Hello from cot!") +//! .build()?; +//! email.send(message).await?; +//! # Ok(()) } +//! ``` + +pub mod transport; + +use std::error::Error as StdError; +use std::sync::Arc; + +use cot::config::{EmailConfig, EmailTransportTypeConfig}; +use cot::email::transport::smtp::Smtp; +use derive_builder::Builder; +use derive_more::with_trait::Debug; +use thiserror::Error; +use transport::{BoxedTransport, Transport}; + +use crate::email::transport::TransportError; +use crate::email::transport::console::Console; +use crate::error::error_impl::impl_into_cot_error; +const ERROR_PREFIX: &str = "email message build error:"; + +/// Represents errors that can occur when sending an email. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum EmailError { + /// An error occurred in the transport layer while sending the email. + #[error(transparent)] + Transport(TransportError), +} + +impl_into_cot_error!(EmailError); + +/// A convenience alias for results returned by email operations. +pub type EmailResult = Result; + +/// Raw attachment data to be embedded into an email. +#[derive(Debug, Clone)] +pub struct AttachmentData { + /// The filename to display for the attachment. + pub filename: String, + /// The MIME content type of the attachment (e.g., `image/png`). + pub content_type: String, + /// The raw bytes of the attachment. + pub data: Vec, +} + +/// A high-level email message representation. +/// +/// This struct encapsulates the components of an email, including +/// subject, body, sender, recipients, and attachments. +#[derive(Debug, Clone, Builder)] +#[builder(build_fn(skip))] +pub struct EmailMessage { + /// The subject of the email. + #[builder(setter(into))] + subject: String, + /// The body content of the email. + #[builder(setter(into))] + body: String, + /// The sender's email address. + from: crate::common_types::Email, + /// The primary recipients of the email. + to: Vec, + /// The carbon copy (CC) recipients of the email. + cc: Vec, + /// The blind carbon copy (BCC) recipients of the email. + bcc: Vec, + /// The reply-to addresses for the email. + reply_to: Vec, + /// Attachments to include with the email. + attachments: Vec, +} + +impl EmailMessage { + /// Create a new builder for constructing an `EmailMessage`. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::Email; + /// use cot::email::EmailMessage; + /// + /// let message = EmailMessage::builder() + /// .from(Email::try_from("no-reply@example.com").unwrap()) + /// .to(vec![Email::try_from("user@example.com").unwrap()]) + /// .subject("Greetings") + /// .body("Hello from cot!") + /// .build() + /// .unwrap(); + /// ``` + #[must_use] + pub fn builder() -> EmailMessageBuilder { + EmailMessageBuilder::default() + } +} + +impl EmailMessageBuilder { + /// Build the `EmailMessage`, ensuring required fields are set. + /// + /// # Errors + /// + /// This method returns an `EmailError` if required fields are missing. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::Email; + /// use cot::email::EmailMessage; + /// + /// let message = EmailMessage::builder() + /// .from(Email::try_from("no-reply@example.com").unwrap()) + /// .to(vec![Email::try_from("user@example.com").unwrap()]) + /// .subject("Greetings") + /// .body("Hello from cot!") + /// .build() + /// .unwrap(); + /// ``` + pub fn build(&self) -> Result { + let from = self + .from + .clone() + .ok_or_else(|| EmailMessageError::MissingField("from".to_string()))?; + + let subject = self.subject.clone().unwrap_or_default(); + let body = self.body.clone().unwrap_or_default(); + + let to = self.to.clone().unwrap_or_default(); + let cc = self.cc.clone().unwrap_or_default(); + let bcc = self.bcc.clone().unwrap_or_default(); + let reply_to = self.reply_to.clone().unwrap_or_default(); + let attachments = self.attachments.clone().unwrap_or_default(); + + Ok(EmailMessage { + subject, + body, + from, + to, + cc, + bcc, + reply_to, + attachments, + }) + } +} + +/// Errors that can occur while building an email message. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum EmailMessageError { + /// An invalid email address was provided. + #[error("{ERROR_PREFIX} invalid email address: {0}")] + InvalidEmailAddress(Box), + /// Failed to build the email message. + #[error("{ERROR_PREFIX} failed to build email message: {0}")] + BuildError(Box), + /// A required field is missing in the email message. + #[error("{ERROR_PREFIX} The `{0}` field is required but was not set")] + MissingField(String), +} + +impl_into_cot_error!(EmailMessageError); + +#[derive(Debug)] +struct EmailImpl { + #[debug("..")] + transport: Box, +} + +/// A high-level email interface for sending emails. +/// +/// This struct wraps a [`Transport`] implementation and provides +/// convenient methods for sending single or multiple email messages. +/// +/// # Examples +/// +/// ``` +/// use cot::common_types::Email; +/// use cot::email::EmailMessage; +/// use cot::email::transport::console::Console; +/// +/// # #[tokio::main] +/// # async fn main() -> cot::Result<()> { +/// let email = cot::email::Email::new(Console::new()); +/// let message = EmailMessage::builder() +/// .from(Email::try_from("no-reply@example.com").unwrap()) +/// .to(vec![Email::try_from("user@example.com").unwrap()]) +/// .subject("Greetings") +/// .body("Hello from cot!") +/// .build()?; +/// email.send(message).await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct Email { + inner: Arc, +} + +impl Email { + /// Create a new email sender using the given transport implementation. + /// + /// # Examples + /// + /// ``` + /// use cot::email::transport::console::Console; + /// use cot::email::{Email, EmailMessage}; + /// + /// let email = Email::new(Console::new()); + /// ``` + pub fn new(transport: impl Transport) -> Self { + let transport: Box = Box::new(transport); + Self { + inner: Arc::new(EmailImpl { transport }), + } + } + /// Send a single [`EmailMessage`] + /// + /// # Errors + /// + /// Returns an `EmailError::Transport` error if sending the email fails. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::Email; + /// use cot::email::EmailMessage; + /// use cot::email::transport::console::Console; + /// + /// # #[tokio::main] + /// # async fn main() -> cot::Result<()> { + /// let email = cot::email::Email::new(Console::new()); + /// let message = EmailMessage::builder() + /// .from(Email::try_from("no-reply@example.com").unwrap()) + /// .to(vec![Email::try_from("user@example.com").unwrap()]) + /// .subject("Greetings") + /// .body("Hello from cot!") + /// .build()?; + /// email.send(message).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send(&self, message: EmailMessage) -> EmailResult<()> { + self.inner + .transport + .send(&[message]) + .await + .map_err(EmailError::Transport) + } + + /// Send multiple emails in sequence. + /// + /// # Errors + /// + /// Returns an `EmailError::Transport` if sending any of the emails fails. + /// + /// # Examples + /// + /// ``` + /// use cot::common_types::Email; + /// use cot::email::EmailMessage; + /// use cot::email::transport::console::Console; + /// + /// # #[tokio::main] + /// # async fn main() -> cot::Result<()> { + /// let email = cot::email::Email::new(Console::new()); + /// let message1 = EmailMessage::builder() + /// .from(Email::try_from("no-reply@email.com").unwrap()) + /// .to(vec![Email::try_from("user1@example.com").unwrap()]) + /// .subject("Hello User 1") + /// .body("This is the first email.") + /// .build()?; + /// + /// let message2 = EmailMessage::builder() + /// .from(Email::try_from("no-reply@email.com").unwrap()) + /// .to(vec![Email::try_from("user2@example.com").unwrap()]) + /// .subject("Hello User 2") + /// .body("This is the second email.") + /// .build()?; + /// email.send_multiple(&[message1, message2]).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_multiple(&self, messages: &[EmailMessage]) -> EmailResult<()> { + self.inner + .transport + .send(messages) + .await + .map_err(EmailError::Transport) + } + + /// Construct an [`Email`] from the provided [`EmailConfig`]. + /// + /// # Errors + /// + /// Returns an `EmailError::Transport` error if creating the transport + /// backend fails from the config. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailConfig, EmailTransportConfig, EmailTransportTypeConfig}; + /// use cot::email::Email; + /// use cot::email::transport::console::Console; + /// + /// let config = EmailConfig { + /// transport: EmailTransportConfig::builder() + /// .transport_type(EmailTransportTypeConfig::Console) + /// .build(), + /// ..Default::default() + /// }; + /// let email = Email::from_config(&config); + /// ``` + pub fn from_config(config: &EmailConfig) -> EmailResult { + let transport = &config.transport; + + let this = { + match &transport.transport_type { + EmailTransportTypeConfig::Console => { + let console = Console::new(); + Self::new(console) + } + + EmailTransportTypeConfig::Smtp { url, mechanism } => { + let smtp = Smtp::new(url, *mechanism).map_err(EmailError::Transport)?; + Self::new(smtp) + } + } + }; + Ok(this) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{EmailTransportConfig, EmailUrl}; + use crate::email::transport::smtp::Mechanism; + + #[cot::test] + async fn builder_errors_when_from_missing() { + let res = EmailMessage::builder() + .subject("Hello".to_string()) + .body("World".to_string()) + .build(); + assert!(res.is_err()); + let err = res.err().unwrap(); + assert_eq!( + err.to_string(), + "email message build error: The `from` field is required but was not set" + ); + } + + #[cot::test] + async fn builder_defaults_when_only_from_set() { + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("sender@example.com").unwrap()) + .build() + .expect("should build with defaults"); + assert_eq!(msg.subject, ""); + assert_eq!(msg.body, ""); + assert!(msg.to.is_empty()); + assert!(msg.cc.is_empty()); + assert!(msg.bcc.is_empty()); + assert!(msg.reply_to.is_empty()); + assert!(msg.attachments.is_empty()); + } + + #[cot::test] + async fn from_config_console_builds() { + use crate::config::{EmailConfig, EmailTransportTypeConfig}; + let cfg = EmailConfig { + transport: EmailTransportConfig { + transport_type: EmailTransportTypeConfig::Console, + }, + }; + let email = Email::from_config(&cfg); + assert!(email.is_ok()); + } + + #[cot::test] + async fn from_config_smtp_builds() { + let cfg = EmailConfig { + transport: EmailTransportConfig { + transport_type: EmailTransportTypeConfig::Smtp { + url: EmailUrl::from("smtp://localhost:1025"), + mechanism: Mechanism::Plain, + }, + }, + }; + let email = Email::from_config(&cfg); + assert!(email.is_ok()); + } + + #[cot::test] + async fn email_send_console() { + let console = Console::new(); + let email = Email::new(console); + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("user@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("recipient@example.com").unwrap(), + ]) + .subject("Test Email".to_string()) + .body("This is a test email body.".to_string()) + .build() + .unwrap(); + + assert!(email.send(msg).await.is_ok()); + } + + #[cot::test] + async fn email_send_multiple_console() { + let console = Console::new(); + let email = Email::new(console); + let msg1 = EmailMessage::builder() + .from(crate::common_types::Email::new("user1@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("recipient@example.com").unwrap(), + ]) + .subject("Test Email") + .body("This is a test email body.") + .build() + .unwrap(); + + let msg2 = EmailMessage::builder() + .from(crate::common_types::Email::new("user2@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("user2@example.com").unwrap(), + ]) + .subject("Another Test Email") + .body("This is another test email body.") + .build() + .unwrap(); + assert!(email.send_multiple(&[msg1, msg2]).await.is_ok()); + } +} diff --git a/cot/src/email/transport.rs b/cot/src/email/transport.rs new file mode 100644 index 00000000..070ad7b7 --- /dev/null +++ b/cot/src/email/transport.rs @@ -0,0 +1,66 @@ +//! This module defines the email transport system for sending emails in Cot. +//! +//! It provides a `Transport` trait that can be implemented by different email +//! backends (e.g., SMTP, console). The module also defines error handling for +//! transport operations. +use std::error::Error as StdError; +use std::future::Future; +use std::pin::Pin; + +use cot::email::EmailMessageError; +use thiserror::Error; + +use crate::email::EmailMessage; +use crate::error::error_impl::impl_into_cot_error; + +pub mod console; +pub mod smtp; + +const ERROR_PREFIX: &str = "email transport error:"; + +/// Errors that can occur while sending an email using a transport backend. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum TransportError { + /// The underlying transport backend returned an error. + #[error("{ERROR_PREFIX} transport error: {0}")] + Backend(Box), + /// Failed to build the email message. + #[error("{ERROR_PREFIX} message build error: {0}")] + MessageBuildError(#[from] EmailMessageError), +} + +impl_into_cot_error!(TransportError); + +/// A Convenience alias for results returned by transport operations. +pub type TransportResult = Result; + +/// A generic asynchronous email transport interface. +/// +/// The `Transport` trait abstracts over different email transport backends. It +/// provides methods to manage sending email messages asynchronously. +pub trait Transport: Send + Sync + 'static { + /// Send one or more email messages. + /// + /// # Errors + /// + /// This method can return an error if there is an issue sending the + /// messages. + fn send(&self, messages: &[EmailMessage]) -> impl Future> + Send; +} + +pub(crate) trait BoxedTransport: Send + Sync + 'static { + fn send<'a>( + &'a self, + messages: &'a [EmailMessage], + ) -> Pin> + Send + 'a>>; +} + +impl BoxedTransport for T { + fn send<'a>( + &'a self, + messages: &'a [EmailMessage], + ) -> Pin> + Send + 'a>> { + Box::pin(async move { T::send(self, messages).await }) + } +} diff --git a/cot/src/email/transport/console.rs b/cot/src/email/transport/console.rs new file mode 100644 index 00000000..12d13b1b --- /dev/null +++ b/cot/src/email/transport/console.rs @@ -0,0 +1,277 @@ +//! Console transport implementation. +//! +//! This backend writes a human-friendly representation of emails to stdout. +//! It is intended primarily for development and testing environments where +//! actually sending email is not required. +//! +//! Typical usage is through the high-level [`crate::email::Email`] API +//! +//! ## Examples +//! +//! ``` +//! use cot::common_types::Email; +//! use cot::email::EmailMessage; +//! use cot::email::transport::console::Console; +//! +//! # #[tokio::main] +//! # async fn main() -> cot::Result<()>{ +//! let email = cot::email::Email::new(Console::new()); +//! let recipients = vec![Email::try_from("testrecipient@example.com").unwrap()]; +//! let msg = EmailMessage::builder() +//! .from(Email::try_from("no-reply@example.com").unwrap()) +//! .to(vec![Email::try_from("user@example.com").unwrap()]) +//! .build()?; +//! email.send(msg).await?; +//! # Ok(()) } +//! ``` +use std::io::Write; +use std::{fmt, io}; + +use cot::email::EmailMessage; +use cot::email::transport::TransportError; +use thiserror::Error; + +use crate::email::transport::{Transport, TransportResult}; + +const ERROR_PREFIX: &str = "console transport error:"; + +/// Errors that can occur while using the console transport. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ConsoleError { + /// An IO error occurred while writing to stdout. + #[error("{ERROR_PREFIX} IO error: {0}")] + Io(#[from] io::Error), +} + +impl From for TransportError { + fn from(err: ConsoleError) -> Self { + TransportError::Backend(Box::new(err)) + } +} + +/// A transport backend that prints emails to stdout. +/// +/// # Examples +/// +/// ``` +/// use cot::email::transport::console::Console; +/// +/// let console_transport = Console::new(); +/// ``` +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct Console; + +impl Console { + /// Create a new console transport backend. + /// + /// # Examples + /// + /// ``` + /// use cot::email::transport::console::Console; + /// + /// let console_transport = Console::new(); + /// ``` + #[must_use] + pub fn new() -> Self { + Self {} + } +} + +impl Default for Console { + fn default() -> Self { + Self::new() + } +} + +impl Transport for Console { + async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> { + let mut out = io::stdout().lock(); + for msg in messages { + writeln!(out, "{msg}").map_err(ConsoleError::Io)?; + writeln!(out, "{}", "─".repeat(60)).map_err(ConsoleError::Io)?; + } + Ok(()) + } +} + +impl fmt::Display for EmailMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let fmt_list = |list: &Vec| -> String { + if list.is_empty() { + "-".to_string() + } else { + list.iter() + .map(|a| a.email().clone()) + .collect::>() + .join(", ") + } + }; + + writeln!( + f, + "════════════════════════════════════════════════════════════════" + )?; + writeln!(f, "From : {}", self.from.email())?; + writeln!(f, "To : {}", fmt_list(&self.to))?; + if !self.cc.is_empty() { + writeln!(f, "Cc : {}", fmt_list(&self.cc))?; + } + if !self.bcc.is_empty() { + writeln!(f, "Bcc : {}", fmt_list(&self.bcc))?; + } + if !self.reply_to.is_empty() { + writeln!(f, "Reply-To: {}", fmt_list(&self.reply_to))?; + } + writeln!( + f, + "Subject : {}", + if self.subject.is_empty() { + "-" + } else { + &self.subject + } + )?; + writeln!( + f, + "────────────────────────────────────────────────────────" + )?; + if self.body.trim().is_empty() { + writeln!(f, "")?; + } else { + writeln!(f, "{}", self.body.trim_end())?; + } + writeln!( + f, + "────────────────────────────────────────────────────────" + )?; + if self.attachments.is_empty() { + writeln!(f, "Attachments: -")?; + } else { + writeln!(f, "Attachments ({}):", self.attachments.len())?; + for a in &self.attachments { + writeln!( + f, + " - {} ({} bytes, {})", + a.filename, + a.data.len(), + a.content_type + )?; + } + } + writeln!( + f, + "════════════════════════════════════════════════════════════════" + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common_types::Email as Addr; + use crate::email::{AttachmentData, Email}; + + #[cot::test] + async fn console_error_to_transport_error() { + let console_error = ConsoleError::Io(io::Error::other("test error")); + let transport_error: TransportError = console_error.into(); + + assert_eq!( + transport_error.to_string(), + "email transport error: transport error: console transport error: IO error: test error" + ); + } + + #[cot::test] + async fn display_full_message_renders_all_sections() { + let msg = EmailMessage::builder() + .from(Addr::new("from@example.com").unwrap()) + .to(vec![ + Addr::new("to1@example.com").unwrap(), + Addr::new("to2@example.com").unwrap(), + ]) + .cc(vec![ + Addr::new("cc1@example.com").unwrap(), + Addr::new("cc2@example.com").unwrap(), + ]) + .bcc(vec![Addr::new("bcc@example.com").unwrap()]) + .reply_to(vec![Addr::new("reply@example.com").unwrap()]) + .subject("Subject Line") + .body("Hello body\n") + .attachments(vec![ + AttachmentData { + filename: "a.txt".into(), + content_type: "text/plain".into(), + data: b"abc".to_vec(), + }, + AttachmentData { + filename: "b.pdf".into(), + content_type: "application/pdf".into(), + data: vec![0u8; 10], + }, + ]) + .build() + .unwrap(); + + let console = Console::new(); + let email = Email::new(console); + email + .send(msg.clone()) + .await + .expect("console send should succeed"); + + let rendered = format!("{msg}"); + + assert!(rendered.contains("From : from@example.com")); + assert!(rendered.contains("To : to1@example.com, to2@example.com")); + assert!(rendered.contains("Subject : Subject Line")); + assert!(rendered.contains("────────────────────────────────────────────────────────")); + + assert!(rendered.contains("Cc : cc1@example.com, cc2@example.com")); + assert!(rendered.contains("Bcc : bcc@example.com")); + assert!(rendered.contains("Reply-To: reply@example.com")); + + assert!(rendered.contains("Hello body")); + + assert!(rendered.contains("Attachments (2):")); + assert!(rendered.contains(" - a.txt (3 bytes, text/plain)")); + assert!(rendered.contains(" - b.pdf (10 bytes, application/pdf)")); + + assert!( + rendered.contains("════════════════════════════════════════════════════════════════") + ); + } + + #[cot::test] + async fn display_minimal_message_renders_placeholders_and_omits_optional_headers() { + let msg = EmailMessage::builder() + .from(Addr::new("sender@example.com").unwrap()) + // whitespace-only body should render as + .body(" \t\n ") + .build() + .unwrap(); + + let console = Console::default(); + let email = Email::new(console); + email + .send(msg.clone()) + .await + .expect("console send should succeed"); + + let rendered = format!("{msg}"); + + assert!(rendered.contains("From : sender@example.com")); + assert!(rendered.contains("To : -")); + assert!(rendered.contains("Subject : -")); + + assert!(!rendered.contains("Cc :")); + assert!(!rendered.contains("Bcc :")); + assert!(!rendered.contains("Reply-To:")); + + assert!(rendered.contains("")); + assert!(rendered.contains("Attachments: -")); + } +} diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs new file mode 100644 index 00000000..91902ea3 --- /dev/null +++ b/cot/src/email/transport/smtp.rs @@ -0,0 +1,375 @@ +//! SMTP transport implementation. +//! +//! This backend sends email messages to a configured remote SMTP +//! server. +//! +//! Typical usage is through the high-level [`crate::email::Email`] API: +//! +//! ```no_run +//! use cot::common_types::Email; +//! use cot::config::EmailUrl; +//! use cot::email::EmailMessage; +//! use cot::email::transport::Transport; +//! use cot::email::transport::smtp::{Mechanism, Smtp}; +//! +//! # async fn run() -> Result<(), Box> { +//! let url = EmailUrl::from("smtps://username:password@smtp.gmail.com?tls=required"); +//! let smtp = Smtp::new(&url, Mechanism::Plain)?; +//! let email = cot::email::Email::new(smtp); +//! let msg = EmailMessage::builder() +//! .from(Email::try_from("user@example.com").unwrap()) +//! .to(vec![Email::try_from("user2@example.com").unwrap()]) +//! .body("This is a test email.") +//! .build()?; +//! email.send(msg).await?; +//! # Ok(()) } +//! ``` +use std::error::Error as StdError; + +use cot::config::EmailUrl; +use cot::email::{EmailMessage, EmailMessageError}; +use lettre::message::header::ContentType; +use lettre::message::{Attachment, Body, Mailbox, MultiPart, SinglePart}; +use lettre::transport::smtp; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::email::transport::{Transport, TransportError, TransportResult}; + +const ERROR_PREFIX: &str = "smtp transport error:"; + +/// Errors produced by the SMTP transport. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SMTPError { + /// An IO error occurred. + #[error("{ERROR_PREFIX} IO error: {0}")] + Io(#[from] std::io::Error), + /// An error occurred while sending the email via SMTP. + #[error("{ERROR_PREFIX} send error: {0}")] + SmtpSend(Box), + /// An error occurred while creating the transport. + #[error("{ERROR_PREFIX} transport creation error: {0}")] + TransportCreation(Box), + /// An error occurred while building the email message. + #[error("{ERROR_PREFIX} message error: {0}")] + MessageBuild(#[from] EmailMessageError), +} + +impl From for TransportError { + fn from(err: SMTPError) -> Self { + match err { + SMTPError::MessageBuild(e) => TransportError::MessageBuildError(e), + other => TransportError::Backend(Box::new(other)), + } + } +} + +/// Supported SMTP authentication mechanisms. +/// +/// The default is `Plain`. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum Mechanism { + /// PLAIN authentication mechanism defined in [RFC 4616](https://tools.ietf.org/html/rfc4616) + /// This is the default authentication mechanism. + #[default] + Plain, + /// LOGIN authentication mechanism defined in + /// [draft-murchison-sasl-login-00](https://www.ietf.org/archive/id/draft-murchison-sasl-login-00.txt). + /// This mechanism is obsolete but needed for some providers (like Office + /// 365). + Login, + /// Non-standard XOAUTH2 mechanism defined in + /// [xoauth2-protocol](https://developers.google.com/gmail/imap/xoauth2-protocol) + Xoauth2, +} + +impl From for smtp::authentication::Mechanism { + fn from(mechanism: Mechanism) -> Self { + match mechanism { + Mechanism::Plain => smtp::authentication::Mechanism::Plain, + Mechanism::Login => smtp::authentication::Mechanism::Login, + Mechanism::Xoauth2 => smtp::authentication::Mechanism::Xoauth2, + } + } +} + +/// SMTP transport backend that sends emails via a remote SMTP server. +/// +/// # Examples +/// +/// ```no_run +/// use cot::email::EmailMessage; +/// use cot::email::transport::Transport; +/// use cot::email::transport::smtp::{Smtp, Mechanism}; +/// use cot::common_types::Email; +/// use cot::config::EmailUrl; +/// +/// # #[tokio::main] +/// # async fn run() -> cot::Result<()> { +/// let url = EmailUrl::from("smtps://johndoe:xxxx xxxxx xxxx xxxxx@smtp.gmail.com"); +/// let smtp = Smtp::new(&url, Mechanism::Plain)?; +/// let email = cot::email::Email::new(smtp); +/// +/// let msg = EmailMessage::builder() +/// .from(Email::try_from("testfrom@example.com").unwrap()) +/// .to(vec![Email::try_from("testreceipient@example.com").unwrap()]) +/// .body("This is a test email.") +/// .build()?; +/// email.send(msg).await?; +/// # Ok(()) } +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Smtp { + transport: AsyncSmtpTransport, +} + +impl Smtp { + /// Create a new SMTP transport backend. + /// + /// # Errors + /// + /// Returns an `SMTP::TransportCreationError` if the Smtp backend creation + /// failed. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailUrl; + /// use cot::email::transport::smtp::{Mechanism, Smtp}; + /// + /// let url = EmailUrl::from("smtps://username:password@smtp.gmail.com?tls=required"); + /// let smtp = Smtp::new(&url, Mechanism::Plain); + /// ``` + pub fn new(url: &EmailUrl, mechanism: Mechanism) -> TransportResult { + let transport = AsyncSmtpTransport::::from_url(url.as_str()) + .map_err(|err| SMTPError::TransportCreation(Box::new(err)))? + .authentication(vec![mechanism.into()]) + .build(); + + Ok(Smtp { transport }) + } +} + +impl Transport for Smtp { + async fn send(&self, messages: &[EmailMessage]) -> TransportResult<()> { + for message in messages { + let m = convert_email_message_to_lettre_message(message.clone())?; + self.transport + .send(m) + .await + .map_err(|err| SMTPError::SmtpSend(Box::new(err)))?; + } + Ok(()) + } +} + +fn convert_email_message_to_lettre_message( + message: EmailMessage, +) -> Result { + let from_mailbox = message + .from + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + + let mut builder = Message::builder() + .from(from_mailbox) + .subject(message.subject); + + for to in message.to { + let mb = to + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + builder = builder.to(mb); + } + + for cc in message.cc { + let mb = cc + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + builder = builder.cc(mb); + } + + for bcc in message.bcc { + let mb = bcc + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + builder = builder.bcc(mb); + } + + for r in message.reply_to { + let mb = r + .email() + .as_str() + .parse::() + .map_err(|err| EmailMessageError::InvalidEmailAddress(Box::new(err)))?; + builder = builder.reply_to(mb); + } + + let mut mixed = MultiPart::mixed().singlepart(SinglePart::plain(message.body)); + + for attach in message.attachments { + let mime: ContentType = attach.content_type.parse().unwrap_or_else(|_| { + "application/octet-stream" + .parse() + .expect("could not parse default mime type") + }); + + let part = Attachment::new(attach.filename).body(Body::new(attach.data), mime); + mixed = mixed.singlepart(part); + } + + let email = builder + .multipart(mixed) + .map_err(|err| EmailMessageError::BuildError(Box::new(err)))?; + Ok(email) +} + +#[cfg(test)] +mod tests { + use cot::email::AttachmentData; + use lettre::transport::smtp; + + use super::*; + + #[cot::test] + async fn test_smtp_creation() { + let url = EmailUrl::from("smtp://user:pass@smtp.gmail.com:587"); + let smtp = Smtp::new(&url, Mechanism::Plain); + assert!(smtp.is_ok()); + } + + #[cot::test] + async fn test_smtp_error_to_transport_error() { + let smtp_error = SMTPError::SmtpSend(Box::new(std::io::Error::other("test"))); + let transport_error: TransportError = smtp_error.into(); + assert_eq!( + transport_error.to_string(), + "email transport error: transport error: smtp transport error: send error: test" + ); + + let smtp_error = SMTPError::TransportCreation(Box::new(std::io::Error::other("test"))); + let transport_error: TransportError = smtp_error.into(); + assert_eq!( + transport_error.to_string(), + "email transport error: transport error: smtp transport error: transport creation error: test" + ); + + let smtp_error = SMTPError::Io(std::io::Error::other("test")); + let transport_error: TransportError = smtp_error.into(); + assert_eq!( + transport_error.to_string(), + "email transport error: transport error: smtp transport error: IO error: test" + ); + } + + #[cot::test] + async fn mechanism_from_maps_all_cases() { + let m_plain: smtp::authentication::Mechanism = Mechanism::Plain.into(); + assert_eq!(m_plain, smtp::authentication::Mechanism::Plain); + + let m_login: smtp::authentication::Mechanism = Mechanism::Login.into(); + assert_eq!(m_login, smtp::authentication::Mechanism::Login); + + let m_xoauth2: smtp::authentication::Mechanism = Mechanism::Xoauth2.into(); + assert_eq!(m_xoauth2, smtp::authentication::Mechanism::Xoauth2); + } + + #[cot::test] + async fn try_from_basic_converts_and_contains_headers() { + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("from@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("to@example.com").unwrap(), + ]) + .subject("Hello World") + .body("This is the body.") + .build() + .unwrap(); + + let built: Message = + convert_email_message_to_lettre_message(msg).expect("conversion to lettre::Message"); + + let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); + + assert!(formatted.contains("From: from@example.com"),); + assert!(formatted.contains("To: to@example.com"),); + assert!(formatted.contains("Subject: Hello World"),); + assert!(formatted.contains("Content-Type: multipart/mixed"),); + assert!(formatted.contains("This is the body."),); + } + + #[cot::test] + async fn try_from_includes_cc_and_reply_to_headers() { + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("sender@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("primary@example.com").unwrap(), + ]) + .cc(vec![ + crate::common_types::Email::new("cc1@example.com").unwrap(), + crate::common_types::Email::new("cc2@example.com").unwrap(), + ]) + .bcc(vec![ + crate::common_types::Email::new("hidden@example.com").unwrap(), + ]) + .reply_to(vec![ + crate::common_types::Email::new("replyto@example.com").unwrap(), + ]) + .subject("Headers Test") + .body("Body") + .build() + .unwrap(); + + let built: Message = + convert_email_message_to_lettre_message(msg).expect("conversion to lettre::Message"); + let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); + + assert!( + formatted.contains("Cc: cc1@example.com, cc2@example.com") + || (formatted.contains("Cc: cc1@example.com") + && formatted.contains("cc2@example.com")), + ); + assert!(formatted.contains("Reply-To: replyto@example.com"),); + } + + #[cot::test] + async fn try_from_with_attachment_uses_default_mime_on_parse_failure() { + let attachment = AttachmentData { + filename: "report.bin".to_string(), + content_type: "this/is not a valid mime".to_string(), + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + }; + + let msg = EmailMessage::builder() + .from(crate::common_types::Email::new("sender@example.com").unwrap()) + .to(vec![ + crate::common_types::Email::new("to@example.com").unwrap(), + ]) + .subject("Attachment Test") + .body("Please see attachment") + .attachments(vec![attachment]) + .build() + .unwrap(); + + let built: Message = + convert_email_message_to_lettre_message(msg).expect("conversion to lettre::Message"); + let formatted = String::from_utf8_lossy(&built.formatted()).to_string(); + + assert!(formatted.contains("Content-Disposition: attachment"),); + assert!(formatted.contains("report.bin"),); + assert!(formatted.contains("Content-Type: application/octet-stream"),); + assert!(formatted.contains("Please see attachment")); + } +} diff --git a/cot/src/lib.rs b/cot/src/lib.rs index 3cfb5994..9776dc41 100644 --- a/cot/src/lib.rs +++ b/cot/src/lib.rs @@ -73,6 +73,8 @@ mod body; pub mod cli; pub mod common_types; pub mod config; +#[cfg(feature = "email")] +pub mod email; mod error_page; #[macro_use] pub(crate) mod handler; diff --git a/cot/src/project.rs b/cot/src/project.rs index c4014cb0..beaaa235 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -51,6 +51,8 @@ use crate::config::{AuthBackendConfig, ProjectConfig}; use crate::db::Database; #[cfg(feature = "db")] use crate::db::migrations::{MigrationEngine, SyncDynMigration}; +#[cfg(feature = "email")] +use crate::email::Email; use crate::error::UncaughtPanic; use crate::error::error_impl::impl_into_cot_error; use crate::error::handler::{DynErrorPageHandler, RequestOuterError}; @@ -1394,7 +1396,6 @@ impl Bootstrapper { }) } } - impl Bootstrapper { /// Returns the context and handlers of the bootstrapper. /// @@ -1526,6 +1527,10 @@ pub trait BootstrapPhase: sealed::Sealed { // App context types /// The type of the configuration. type Config: Debug; + /// The type of the email service. + #[cfg(feature = "email")] + type Email: Debug; + /// The type of the apps. type Apps; /// The type of the router. @@ -1554,6 +1559,8 @@ impl BootstrapPhase for Uninitialized { type RequestHandler = (); type ErrorHandler = (); type Config = (); + #[cfg(feature = "email")] + type Email = (); type Apps = (); type Router = (); #[cfg(feature = "db")] @@ -1577,6 +1584,8 @@ impl BootstrapPhase for WithConfig { type RequestHandler = (); type ErrorHandler = (); type Config = Arc; + #[cfg(feature = "email")] + type Email = Email; type Apps = (); type Router = (); #[cfg(feature = "db")] @@ -1600,6 +1609,8 @@ impl BootstrapPhase for WithApps { type RequestHandler = (); type ErrorHandler = (); type Config = ::Config; + #[cfg(feature = "email")] + type Email = ::Email; type Apps = Vec>; type Router = Arc; #[cfg(feature = "db")] @@ -1623,6 +1634,8 @@ impl BootstrapPhase for WithDatabase { type RequestHandler = (); type ErrorHandler = (); type Config = ::Config; + #[cfg(feature = "email")] + type Email = ::Email; type Apps = ::Apps; type Router = ::Router; #[cfg(feature = "db")] @@ -1646,6 +1659,8 @@ impl BootstrapPhase for WithCache { type RequestHandler = (); type ErrorHandler = (); type Config = ::Config; + #[cfg(feature = "email")] + type Email = ::Email; type Apps = ::Apps; type Router = ::Router; #[cfg(feature = "db")] @@ -1669,6 +1684,8 @@ impl BootstrapPhase for Initialized { type RequestHandler = BoxedHandler; type ErrorHandler = BoxedHandler; type Config = ::Config; + #[cfg(feature = "email")] + type Email = ::Email; type Apps = ::Apps; type Router = ::Router; #[cfg(feature = "db")] @@ -1692,6 +1709,8 @@ pub struct ProjectContext { auth_backend: S::AuthBackend, #[cfg(feature = "cache")] cache: S::Cache, + #[cfg(feature = "email")] + email: S::Email, } impl ProjectContext { @@ -1706,10 +1725,19 @@ impl ProjectContext { auth_backend: (), #[cfg(feature = "cache")] cache: (), + #[cfg(feature = "email")] + email: (), } } fn with_config(self, config: ProjectConfig) -> ProjectContext { + #[cfg(feature = "email")] + let email = { + Email::from_config(&config.email).unwrap_or_else(|err| { + panic!("failed to initialize email service: {err:?}"); + }) + }; + ProjectContext { config: Arc::new(config), apps: self.apps, @@ -1719,6 +1747,8 @@ impl ProjectContext { auth_backend: self.auth_backend, #[cfg(feature = "cache")] cache: self.cache, + #[cfg(feature = "email")] + email, } } } @@ -1761,6 +1791,8 @@ impl ProjectContext { auth_backend: self.auth_backend, #[cfg(feature = "cache")] cache: self.cache, + #[cfg(feature = "email")] + email: self.email, } } } @@ -1802,6 +1834,8 @@ impl ProjectContext { auth_backend: self.auth_backend, #[cfg(feature = "cache")] cache: self.cache, + #[cfg(feature = "email")] + email: self.email, } } } @@ -1818,6 +1852,8 @@ impl ProjectContext { database: self.database, #[cfg(feature = "cache")] cache, + #[cfg(feature = "email")] + email: self.email, } } } @@ -1834,10 +1870,11 @@ impl ProjectContext { database: self.database, #[cfg(feature = "cache")] cache: self.cache, + #[cfg(feature = "email")] + email: self.email, } } } - impl ProjectContext { #[cfg(feature = "test")] pub(crate) fn initialized( @@ -1847,6 +1884,7 @@ impl ProjectContext { auth_backend: ::AuthBackend, #[cfg(feature = "db")] database: ::Database, #[cfg(feature = "cache")] cache: ::Cache, + #[cfg(feature = "email")] email: ::Email, ) -> Self { Self { config, @@ -1857,6 +1895,8 @@ impl ProjectContext { auth_backend, #[cfg(feature = "cache")] cache, + #[cfg(feature = "email")] + email, } } } @@ -1907,6 +1947,28 @@ impl>> ProjectContext { } } +#[cfg(feature = "email")] +impl> ProjectContext { + #[must_use] + /// Returns the email service for the project. + /// + /// # Examples + /// + /// ``` + /// use cot::request::{Request, RequestExt}; + /// use cot::response::Response; + /// + /// async fn index(request: Request) -> cot::Result { + /// let email = request.context().email(); + /// // ... + /// # unimplemented!() + /// } + /// ``` + pub fn email(&self) -> &Email { + &self.email + } +} + #[cfg(feature = "cache")] impl> ProjectContext { /// Returns the cache for the project. diff --git a/cot/src/request.rs b/cot/src/request.rs index 687be839..6098ef34 100644 --- a/cot/src/request.rs +++ b/cot/src/request.rs @@ -20,6 +20,8 @@ use indexmap::IndexMap; #[cfg(feature = "db")] use crate::db::Database; +#[cfg(feature = "email")] +use crate::email::Email; use crate::error::error_impl::impl_into_cot_error; use crate::request::extractors::FromRequestHead; use crate::router::Router; @@ -209,6 +211,24 @@ pub trait RequestExt: private::Sealed { #[must_use] fn db(&self) -> &Database; + /// Get the email service. + /// + /// # Examples + /// + /// ``` + /// use cot::request::{Request, RequestExt}; + /// use cot::response::Response; + /// + /// async fn my_handler(mut request: Request) -> cot::Result { + /// let email_service = request.email(); + /// // ... do something with the email service + /// # unimplemented!() + /// } + /// ``` + #[cfg(feature = "email")] + #[must_use] + fn email(&self) -> &Email; + /// Get the content type of the request. /// /// # Examples @@ -322,6 +342,11 @@ impl RequestExt for Request { self.context().database() } + #[cfg(feature = "email")] + fn email(&self) -> &Email { + self.context().email() + } + fn content_type(&self) -> Option<&http::HeaderValue> { self.headers().get(http::header::CONTENT_TYPE) } @@ -382,6 +407,11 @@ impl RequestExt for RequestHead { self.context().database() } + #[cfg(feature = "email")] + fn email(&self) -> &Email { + self.context().email() + } + fn content_type(&self) -> Option<&http::HeaderValue> { self.headers.get(http::header::CONTENT_TYPE) } diff --git a/cot/src/test.rs b/cot/src/test.rs index dac987e1..20294b9a 100644 --- a/cot/src/test.rs +++ b/cot/src/test.rs @@ -37,6 +37,10 @@ use crate::db::Database; use crate::db::migrations::{ DynMigration, MigrationDependency, MigrationEngine, MigrationWrapper, Operation, }; +#[cfg(feature = "email")] +use crate::email::Email; +#[cfg(feature = "email")] +use crate::email::transport::console::Console; #[cfg(feature = "redis")] use crate::error::error_impl::impl_into_cot_error; use crate::handler::BoxedHandler; @@ -234,6 +238,8 @@ pub struct TestRequestBuilder { static_files: Vec, #[cfg(feature = "cache")] cache: Option, + #[cfg(feature = "email")] + email: Option, } /// A wrapper over an auth backend that is cloneable. @@ -289,6 +295,8 @@ impl Default for TestRequestBuilder { static_files: Vec::new(), #[cfg(feature = "cache")] cache: None, + #[cfg(feature = "email")] + email: None, } } } @@ -774,6 +782,10 @@ impl TestRequestBuilder { self.cache .clone() .unwrap_or_else(|| Cache::new(Memory::new(), None, Timeout::default())), + #[cfg(feature = "email")] + self.email + .clone() + .unwrap_or_else(|| Email::new(Console::new())), ); prepare_request(&mut request, Arc::new(context)); diff --git a/deny.toml b/deny.toml index 6c253ceb..d1a9a769 100644 --- a/deny.toml +++ b/deny.toml @@ -16,6 +16,9 @@ all-features = true allow = [ "MIT", "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "CDLA-Permissive-2.0", + "ISC", "Unicode-3.0", "0BSD", "BSD-3-Clause", diff --git a/examples/send-email/Cargo.toml b/examples/send-email/Cargo.toml new file mode 100644 index 00000000..05c97084 --- /dev/null +++ b/examples/send-email/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "example-send-email" +version = "0.1.0" +publish = false +description = "Send email - Cot example." +license = "MIT OR Apache-2.0" +edition = "2024" + +[dependencies] +cot = { path = "../../cot", features = ["email", "live-reload"] } +askama = "0.15" +serde = { version = "1", features = ["derive"] } diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs new file mode 100644 index 00000000..3356900d --- /dev/null +++ b/examples/send-email/src/main.rs @@ -0,0 +1,155 @@ +use askama::Template; +use cot::cli::CliMetadata; +use cot::common_types::Email; +use cot::config::{EmailConfig, EmailTransportConfig, EmailTransportTypeConfig, ProjectConfig}; +use cot::email::EmailMessage; +use cot::form::Form; +use cot::html::Html; +use cot::middleware::LiveReloadMiddleware; +use cot::project::{RegisterAppsContext, RootHandler}; +use cot::request::extractors::{StaticFiles, UrlQuery}; +use cot::request::{Request, RequestExt}; +use cot::response::Response; +use cot::router::{Route, Router, Urls}; +use cot::static_files::{StaticFile, StaticFilesMiddleware}; +use cot::{App, AppBuilder, Project, reverse_redirect, static_files}; +use serde::{Deserialize, Serialize}; + +struct EmailApp; + +impl App for EmailApp { + fn name(&self) -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn router(&self) -> Router { + Router::with_urls([ + Route::with_handler_and_name("/", index, "index/"), + Route::with_handler_and_name("/send", send_email, "send_email"), + ]) + } + + fn static_files(&self) -> Vec { + static_files!("css/main.css") + } +} + +#[derive(Debug, Form)] +struct EmailForm { + from: Email, + to: Email, + subject: String, + message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +enum Status { + Success, + Failure, +} + +impl std::fmt::Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Status::Success => write!(f, "Success"), + Status::Failure => write!(f, "Failure"), + } + } +} + +#[derive(Debug, Template)] +#[allow(unused)] +#[template(path = "index.html")] +struct IndexTemplate<'a> { + static_files: StaticFiles, + urls: &'a Urls, + form: ::Context, + status: &'a str, +} + +#[derive(Serialize, Deserialize, Debug)] +struct IndexQuery { + status: Option, +} + +async fn index( + urls: Urls, + mut request: Request, + static_files: StaticFiles, + UrlQuery(query): UrlQuery, +) -> cot::Result { + let status = match query.status { + Some(s) => s.to_string(), + None => "".to_string(), + }; + let index_template = IndexTemplate { + urls: &urls, + form: EmailForm::build_context(&mut request).await?, + status: &status, + static_files, + }; + let rendered = index_template.render()?; + + Ok(Html::new(rendered)) +} + +async fn send_email(urls: Urls, mut request: Request) -> cot::Result { + let form = EmailForm::from_request(&mut request).await?; + + let form = form.unwrap(); + + let message = EmailMessage::builder() + .from(form.from) + .to(vec![form.to]) + .subject(form.subject) + .body(form.message) + .build()?; + + request.email().send(message).await?; + + // TODO: We should redirect with the status when reverse_redirect! supports + // query parameters. + Ok(reverse_redirect!(&urls, "index/")?) +} + +struct MyProject; +impl Project for MyProject { + fn cli_metadata(&self) -> CliMetadata { + cot::cli::metadata!() + } + + fn config(&self, _config_name: &str) -> cot::Result { + Ok(ProjectConfig::builder() + .email( + EmailConfig::builder() + .transport( + EmailTransportConfig::builder() + .transport_type(EmailTransportTypeConfig::Console) + .build(), + ) + .build(), + ) + .build()) + } + + fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { + apps.register_with_views(EmailApp, ""); + } + + fn middlewares( + &self, + handler: cot::project::RootHandlerBuilder, + context: &cot::project::MiddlewareContext, + ) -> RootHandler { + handler + .middleware(StaticFilesMiddleware::from_context(context)) + .middleware(LiveReloadMiddleware::from_context(context)) + .build() + } +} + +#[cot::main] +fn main() -> impl Project { + MyProject +} diff --git a/examples/send-email/static/css/main.css b/examples/send-email/static/css/main.css new file mode 100644 index 00000000..f63ccaae --- /dev/null +++ b/examples/send-email/static/css/main.css @@ -0,0 +1,132 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: #fafafa; + color: #0f172a; +} + +.root-container { + max-width: 480px; + margin: 3rem auto; + padding: 0 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.status { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 12px; + font-size: 0.95rem; + line-height: 1.3; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); + border: 1px solid transparent; + width: 100%; +} + +.status--success { + background: linear-gradient(180deg, #f0fdf4, #e8fff6); + border-color: #b7f2d0; + color: #053516; +} + +.status--error { + background: linear-gradient(180deg, #fff5f5, #ffecec); + border-color: #f4b3b3; + color: #5a0b0b; +} + +.status__text { + margin: 0; + padding: 0; +} + +.status--success .status__text::before, +.status--error .status__text::before { + content: ""; + display: inline-block; + width: 24px; + height: 24px; + border-radius: 6px; + margin-right: 0.5rem; + vertical-align: middle; +} + +.status--success .status__text::before { + background-color: rgba(4, 107, 47, 0.12); + border: 1px solid rgba(4, 107, 47, 0.2); +} + +.status--error .status__text::before { + background-color: rgba(122, 5, 5, 0.12); + border: 1px solid rgba(122, 5, 5, 0.2); +} + +#email-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.form-field label { + font-size: 0.85rem; + font-weight: 500; + color: #334155; +} + +#email-form input, +#email-form textarea { + width: 100%; + padding: 0.6rem 0.75rem; + font-size: 0.95rem; + border-radius: 8px; + border: 1px solid #cbd5e1; + background-color: #ffffff; +} + +#email-form textarea { + resize: vertical; + min-height: 120px; +} + +#email-form input:focus, +#email-form textarea:focus { + outline: none; + border-color: #22c55e; + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); +} + +#email-form .submit-button { + width: 100%; + padding: 0.75rem; + font-size: 1rem; + font-weight: 600; + border-radius: 10px; + border: none; + cursor: pointer; + background: linear-gradient(180deg, #22c55e, #16a34a); + color: white; +} + +#email-form .submit-button:hover { + background: linear-gradient(180deg, #16a34a, #15803d); +} + +#email-form .submit-button:active { + transform: translateY(1px); +} diff --git a/examples/send-email/templates/index.html b/examples/send-email/templates/index.html new file mode 100644 index 00000000..bfe49941 --- /dev/null +++ b/examples/send-email/templates/index.html @@ -0,0 +1,71 @@ +{% let urls = urls %} +{% let status = status %} + + + + + + Send Email Example + + + +
+ {% if status == "Success" %} +
+

Email sent successfully

+
+ {% elif status == "Failure" %} +
+

Failed to send email

+
+ {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +