diff --git a/Cargo.lock b/Cargo.lock index ebce5601..0a178159 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,14 +19,14 @@ checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] @@ -298,9 +298,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", "cfg-if", @@ -340,9 +340,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ "serde", ] @@ -406,9 +406,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.19" +version = "1.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" +checksum = "32db95edf998450acc7881c932f94cd9b05c87b4b2599e8bab064753da4acfd1" dependencies = [ "shlex", ] @@ -431,11 +431,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "clap" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" dependencies = [ "clap_builder", "clap_derive", @@ -453,9 +463,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" dependencies = [ "anstream", "anstyle", @@ -466,9 +476,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.48" +version = "4.5.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be8c97f3a6f02b9e24cadc12aaba75201d18754b53ea0a9d99642f806ccdb4c9" +checksum = "c91d3baa3bcd889d60e6ef28874126a0b384fd225ab83aa6d8a801c519194ce1" dependencies = [ "clap", ] @@ -610,6 +620,7 @@ dependencies = [ "http-body-util", "humantime", "indexmap", + "lettre", "mime_guess", "mockall", "password-auth", @@ -713,9 +724,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] @@ -861,9 +872,9 @@ dependencies = [ [[package]] name = "deunicode" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "difflib" @@ -945,6 +956,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 0.22.1", + "memchr", +] + [[package]] name = "email_address" version = "0.2.9" @@ -1279,9 +1300,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", @@ -1336,9 +1357,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" dependencies = [ "allocator-api2", "equivalent", @@ -1351,7 +1372,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.2", + "hashbrown 0.15.3", ] [[package]] @@ -1393,6 +1414,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "0.2.12" @@ -1538,21 +1570,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", @@ -1561,31 +1594,11 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", @@ -1593,67 +1606,54 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "2549ca8c7241c82f59c80ba2a6f415d931c5b58d24fb8412caa1a1f02c49139a" dependencies = [ "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "potential_utf", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "8197e866e47b68f8f7d95249e172903bec06004b18b2937f1095d40a0c57de04" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", + "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "ident_case" version = "1.0.1" @@ -1673,9 +1673,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -1688,7 +1688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "serde", ] @@ -1767,6 +1767,31 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ffd14fa289730e3ad68edefdc31f603d56fe716ec38f2076bb7410e09147c2" +dependencies = [ + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "url", +] + [[package]] name = "libc" version = "0.2.172" @@ -1775,9 +1800,9 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libm" -version = "0.2.13" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9627da5196e5d8ed0b0495e61e518847578da83483c37288316d9b2e03a7f72" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libsqlite3-sys" @@ -1798,9 +1823,9 @@ checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" @@ -1929,6 +1954,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2041,9 +2075,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.107" +version = "0.9.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" dependencies = [ "cc", "libc", @@ -2131,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a98c6720655620a521dcc722d0ad66cd8afd5d86e34a89ef691c50b7b24de06" dependencies = [ "fixedbitset", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "indexmap", ] @@ -2216,6 +2250,15 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2228,7 +2271,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.24", + "zerocopy", ] [[package]] @@ -2286,6 +2329,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" +dependencies = [ + "cc", +] + [[package]] name = "quote" version = "1.0.40" @@ -2295,6 +2347,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.2.0" @@ -2357,14 +2415,14 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", ] [[package]] name = "redox_syscall" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" dependencies = [ "bitflags", ] @@ -2453,9 +2511,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" -version = "1.0.5" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ "bitflags", "errno", @@ -2518,9 +2576,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sea-query" -version = "0.32.4" +version = "0.32.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d99447c24da0cded00089e2021e1624af90878c65f7534319448d01da3df869d" +checksum = "5506de3a33d9ee4ee161c5847acb87fe4f82ced6649afc9eabeb8df6f40ba94a" dependencies = [ "chrono", "inherent", @@ -2560,6 +2618,14 @@ dependencies = [ "libc", ] +[[package]] +name = "send-email" +version = "0.1.0" +dependencies = [ + "cot", + "lettre", +] + [[package]] name = "serde" version = "1.0.219" @@ -2792,7 +2858,7 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "hashlink", "indexmap", "log", @@ -2961,6 +3027,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3013,9 +3092,9 @@ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", @@ -3030,12 +3109,12 @@ checksum = "1ac9aa371f599d22256307c24a9d748c041e548cbf599f35d890f9d365361790" [[package]] name = "tempfile" -version = "3.19.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.3.3", "once_cell", "rustix", "windows-sys 0.59.0", @@ -3149,9 +3228,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -3174,9 +3253,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.2" +version = "1.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" dependencies = [ "backtrace", "bytes", @@ -3223,9 +3302,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.20" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" dependencies = [ "serde", "serde_spanned", @@ -3235,26 +3314,33 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.24" +version = "0.22.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", + "toml_write", "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" + [[package]] name = "tower" version = "0.5.2" @@ -3453,9 +3539,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "trybuild" -version = "1.0.104" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ae08be68c056db96f0e6c6dd820727cca756ced9e1f4cc7fdd20e2a55e23898" +checksum = "1c9bf9513a2f4aeef5fdac8677d7d349c79fdbcc03b9c86da6e9d254f1e43be2" dependencies = [ "dissimilar", "glob", @@ -3530,12 +3616,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -3933,9 +4013,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.6" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10" +checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" dependencies = [ "memchr", ] @@ -3949,23 +4029,17 @@ dependencies = [ "bitflags", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" dependencies = [ "serde", "stable_deref_trait", @@ -3975,9 +4049,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", @@ -3987,38 +4061,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.24" +version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" dependencies = [ - "zerocopy-derive 0.8.24", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" dependencies = [ "proc-macro2", "quote", @@ -4052,11 +4106,22 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" dependencies = [ "yoke", "zerofrom", @@ -4065,9 +4130,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index bb0b081e..297579a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "examples/json", "examples/custom-task", "examples/custom-error-pages", + "examples/send-email", ] resolver = "2" @@ -91,6 +92,7 @@ humantime = "2" indexmap = "2" insta = { version = "1", features = ["filters"] } insta-cmd = "0.6" +lettre = { version = "0.11", features = ["smtp-transport", "builder", "native-tls"] } mime_guess = { version = "2", default-features = false } mockall = "0.13" password-auth = { version = "1", default-features = false } diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 26004189..a904e87b 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -40,6 +40,7 @@ http-body.workspace = true http.workspace = true humantime.workspace = true indexmap.workspace = true +lettre.workspace = true mime_guess.workspace = true password-auth = { workspace = true, features = ["std", "argon2"] } pin-project-lite.workspace = true diff --git a/cot/src/config.rs b/cot/src/config.rs index 37024064..f24194a5 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -22,6 +22,8 @@ use derive_more::with_trait::{Debug, From}; use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; +use crate::email; + /// The configuration for a project. /// /// This is all the project-specific configuration data that can (and makes @@ -211,6 +213,24 @@ pub struct ProjectConfig { /// # Ok::<(), cot::Error>(()) /// ``` pub middlewares: MiddlewareConfig, + /// Configuration related to the email backend. + /// + /// # Examples + /// + /// ``` + /// use cot::config::{EmailBackendConfig, ProjectConfig}; + /// + /// let config = ProjectConfig::from_toml( + /// r#" + /// [email_backend] + /// type = "none" + /// "#, + /// )?; + /// + /// assert_eq!(config.email_backend, EmailBackendConfig::default()); + /// # Ok::<(), cot::Error>(()) + /// ``` + pub email_backend: EmailBackendConfig, } const fn default_debug() -> bool { @@ -313,6 +333,7 @@ impl ProjectConfigBuilder { database: self.database.clone().unwrap_or_default(), static_files: self.static_files.clone().unwrap_or_default(), middlewares: self.middlewares.clone().unwrap_or_default(), + email_backend: self.email_backend.clone().unwrap_or_default(), } } } @@ -809,7 +830,104 @@ impl SessionMiddlewareConfigBuilder { } } } +/// The type of email backend to use. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EmailBackendType { + /// No email backend. + #[default] + None, + /// SMTP email backend. + Smtp, +} +/// The configuration for the SMTP backend. +/// +/// This is used as part of the [`EmailBackendConfig`] enum. +/// +/// # Examples +/// +/// ``` +/// use cot::config::EmailBackendConfig; +/// +/// let config = EmailBackendConfig::builder().build(); +/// ``` +#[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[builder(build_fn(skip, error = std::convert::Infallible))] +#[serde(default)] +pub struct EmailBackendConfig { + /// The type of email backend to use. + /// Defaults to `None`. + #[builder(setter(into, strip_option), default)] + pub backend_type: EmailBackendType, + /// The SMTP server host address. + /// Defaults to "localhost". + #[builder(setter(into, strip_option), default)] + pub smtp_mode: email::SmtpTransportMode, + /// The SMTP server port. + /// Overwrites the default standard port when specified. + #[builder(setter(into, strip_option), default)] + pub port: Option, + /// The username for SMTP authentication. + #[builder(setter(into, strip_option), default)] + pub username: Option, + /// The password for SMTP authentication. + #[builder(setter(into, strip_option), default)] + pub password: Option, + /// The timeout duration for the SMTP connection. + #[builder(setter(into, strip_option), default)] + pub timeout: Option, +} +impl EmailBackendConfig { + /// Create a new [`EmailBackendConfigBuilder`] to build a + /// [`EmailBackendConfig`]. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailBackendConfig; + /// + /// let config = EmailBackendConfig::builder().build(); + /// ``` + #[must_use] + pub fn builder() -> EmailBackendConfigBuilder { + EmailBackendConfigBuilder::default() + } +} +impl EmailBackendConfigBuilder { + /// Builds the email configuration. + /// + /// # Examples + /// + /// ``` + /// use cot::config::EmailBackendConfig; + /// + /// let config = EmailBackendConfig::builder().build(); + /// ``` + #[must_use] + pub fn build(&self) -> EmailBackendConfig { + match self.backend_type.clone().unwrap_or(EmailBackendType::None) { + EmailBackendType::Smtp => EmailBackendConfig { + backend_type: EmailBackendType::Smtp, + smtp_mode: self + .smtp_mode + .clone() + .unwrap_or(email::SmtpTransportMode::Localhost), + port: self.port.unwrap_or_default(), + username: self.username.clone().unwrap_or_default(), + password: self.password.clone().unwrap_or_default(), + timeout: self.timeout.unwrap_or_default(), + }, + EmailBackendType::None => EmailBackendConfig { + backend_type: EmailBackendType::None, + smtp_mode: email::SmtpTransportMode::Localhost, + port: None, + username: None, + password: None, + timeout: None, + }, + } + } +} /// A secret key. /// /// This is a wrapper over a byte array, which is used to store a cryptographic @@ -1024,6 +1142,8 @@ mod tests { live_reload.enabled = true [middlewares.session] secure = false + [email_backend] + type = "none" "#; let config = ProjectConfig::from_toml(toml_content).unwrap(); @@ -1046,6 +1166,7 @@ mod tests { ); assert!(config.middlewares.live_reload.enabled); assert!(!config.middlewares.session.secure); + assert_eq!(config.email_backend.backend_type, EmailBackendType::None); } #[test] diff --git a/cot/src/email.rs b/cot/src/email.rs new file mode 100644 index 00000000..7a661219 --- /dev/null +++ b/cot/src/email.rs @@ -0,0 +1,1110 @@ +//! Email sending functionality using SMTP and other backends +//! +//! #Examples +//! To send an email using the `EmailBackend`, you need to create an instance of +//! `SmtpConfig` +//! ``` +//! use cot::email::{EmailBackend, EmailMessage, SmtpConfig, SmtpEmailBackend}; +//! fn test_send_email_localhsot() { +//! // Create a test email +//! let email = EmailMessage { +//! subject: "Test Email".to_string(), +//! from: String::from("").into(), +//! to: vec!["".to_string()], +//! body: "This is a test email sent from Rust.".to_string(), +//! alternative_html: Some( +//! "

This is a test email sent from Rust as HTML.

".to_string(), +//! ), +//! ..Default::default() +//! }; +//! let config = SmtpConfig::default(); +//! // Create a new email backend +//! let mut backend = SmtpEmailBackend::new(config); +//! let _ = backend.send_message(&email); +//! } +//! ``` +use std::time::Duration; + +use derive_builder::Builder; +use lettre::message::{Mailbox, Message, MultiPart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{SmtpTransport, Transport}; +#[cfg(test)] +use mockall::{automock, predicate::*}; +use serde::{Deserialize, Serialize}; + +/// Represents errors that can occur when sending an email. +#[derive(Debug, thiserror::Error)] +pub enum EmailError { + /// An error occurred while building the email message. + #[error("Message error: {0}")] + MessageError(String), + /// The email configuration is invalid. + #[error("Invalid email configuration: {0}")] + ConfigurationError(String), + /// An error occurred while sending the email. + #[error("Send error: {0}")] + SendError(String), + /// An error occurred while connecting to the SMTP server. + #[error("Connection error: {0}")] + ConnectionError(String), +} + +type Result = std::result::Result; + +/// Represents the mode of SMTP transport to initialize the backend with. +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SmtpTransportMode { + /// No SMTP transport. + None, + /// Use the default SMTP transport for localhost. + #[default] + Localhost, + /// Use an unencrypted SMTP connection to the specified host. + Unencrypted(String), + /// Use a relay SMTP connection to the specified host. + Relay(String), + /// Use a STARTTLS relay SMTP connection to the specified host. + StartTlsRelay(String), +} + +/// Represents the state of a transport mechanism for SMTP communication. +/// +/// The `TransportState` enum is used to define whether the transport is +/// uninitialized (default state) or initialized with specific settings. +/// +/// # Examples +/// +/// ``` +/// use cot::email::TransportState; +/// +/// let state = TransportState::Uninitialized; // Default state +/// match state { +/// TransportState::Uninitialized => println!("Transport is not initialized."), +/// TransportState::Initialized => println!("Transport is initialized."), +/// } +/// ``` +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportState { + /// Use the default SMTP transport for localhost. + #[default] + Uninitialized, + /// Use an unencrypted SMTP connection to the specified host. + Initialized, +} +/// Represents an email address with an optional name. +#[derive(Debug, Clone, Default)] +pub struct EmailAddress { + /// The email address. + pub address: String, + /// The optional name associated with the email address. + pub name: Option, +} +/// Holds the contents of the email prior to converting to +/// a lettre Message. +#[derive(Debug, Clone, Default)] +pub struct EmailMessage { + /// The subject of the email. + pub subject: String, + /// The body of the email. + pub body: String, + /// The email address of the sender. + pub from: EmailAddress, + /// The list of recipient email addresses. + pub to: Vec, + /// The list of CC (carbon copy) recipient email addresses. + pub cc: Option>, + /// The list of BCC (blind carbon copy) recipient email addresses. + pub bcc: Option>, + /// The list of reply-to email addresses. + pub reply_to: Option>, + /// The alternative parts of the email (e.g., plain text and HTML versions). + pub alternative_html: Option, // (content, mimetype) +} + +/// Configuration for SMTP email backend +#[derive(Debug, Builder, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SmtpConfig { + /// The SMTP server host address. + /// Defaults to "localhost". + pub mode: SmtpTransportMode, + /// The SMTP server port. + /// Overwrites the default standard port when specified. + pub port: Option, + /// The username for SMTP authentication. + pub username: Option, + /// The password for SMTP authentication. + pub password: Option, + /// The timeout duration for the SMTP connection. + pub timeout: Option, +} + +/// SMTP Backend for sending emails +//#[allow(missing_debug_implementations)] +#[derive(Debug)] +pub struct SmtpEmailBackend { + /// The SMTP configuration. + config: SmtpConfig, + /// The SMTP transport. + /// This field is optional because the transport may not be initialized yet. + /// It will be initialized when the `open` method is called. + transport: Option>, + /// Whether or not to print debug information. + debug: bool, + transport_state: TransportState, +} +impl std::fmt::Debug for dyn EmailTransport + 'static { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmailTransport").finish() + } +} +/// Default implementation for `SmtpConfig`. +/// This provides default values for the SMTP configuration fields. +/// The default mode is `Localhost`, with no port, username, or password. +/// The default timeout is set to 60 seconds. +/// This allows for easy creation of a default SMTP configuration +/// without needing to specify all the fields explicitly. +impl Default for SmtpConfig { + fn default() -> Self { + Self { + mode: SmtpTransportMode::None, + port: None, + username: None, + password: None, + timeout: Some(Duration::from_secs(60)), + } + } +} + +impl SmtpConfig { + /// Create a new instance of the SMTP configuration with the given mode. + #[must_use] + pub fn new(mode: SmtpTransportMode) -> Self { + Self { + mode, + ..Default::default() + } + } + fn validate(&self) -> Result<&Self> { + // Check if username and password are both provided both must be Some or both + // None + if self.username.is_some() && self.password.is_none() + || self.username.is_none() && self.password.is_some() + { + return Err(EmailError::ConfigurationError( + "Both username and password must be provided for SMTP authentication".to_string(), + )); + } + let host = match &self.mode { + SmtpTransportMode::Unencrypted(host) => host, + SmtpTransportMode::Relay(host_relay) => host_relay, + SmtpTransportMode::StartTlsRelay(host_tls) => host_tls, + SmtpTransportMode::Localhost => &"localhost".to_string(), + SmtpTransportMode::None => &String::new(), + }; + if host.is_empty() && self.mode != SmtpTransportMode::None { + return Err(EmailError::ConfigurationError( + "Host cannot be empty or blank".to_string(), + )); + } + Ok(self) + } +} +/// Convert ``AddressError`` to ``EmailError`` using ``From`` trait +impl From for EmailError { + fn from(error: lettre::address::AddressError) -> Self { + EmailError::MessageError(format!("Invalid email address: {error}")) + } +} +/// Convert ``EmailAddress`` to ``Mailbox`` using ``TryFrom`` trait +impl TryFrom<&EmailAddress> for Mailbox { + type Error = EmailError; + + fn try_from(email: &EmailAddress) -> Result { + if email.address.is_empty() { + return Err(EmailError::ConfigurationError( + "Email address cannot be empty".to_string(), + )); + } + + if email.name.is_none() { + Ok(format!("<{}>", email.address).parse()?) + } else { + Ok(format!("\"{}\" <{}>", email.name.as_ref().unwrap(), email.address).parse()?) + } + } +} +/// Convert ``String`` to ``EmailAddress`` using ``From`` trait +impl From for EmailAddress { + fn from(address: String) -> Self { + Self { + address, + name: None, + } + } +} +/// Convert ``SmtpConfig`` to Credentials using ``TryFrom`` trait +impl TryFrom<&SmtpConfig> for Credentials { + type Error = EmailError; + + fn try_from(config: &SmtpConfig) -> Result { + match (&config.username, &config.password) { + (Some(username), Some(password)) => { + Ok(Credentials::new(username.clone(), password.clone())) + } + (Some(_), None) | (None, Some(_)) => Err(EmailError::ConfigurationError( + "Both username and password must be provided for SMTP authentication".to_string(), + )), + (None, None) => Ok(Credentials::new(String::new(), String::new())), + } + } +} +/// Convert ``EmailMessage`` to ``Message`` using ``TryFrom`` trait +impl TryFrom<&EmailMessage> for Message { + type Error = EmailError; + + fn try_from(email: &EmailMessage) -> Result { + // Create a simple email for testing + let mut builder = Message::builder() + .subject(email.subject.clone()) + .from(Mailbox::try_from(&email.from)?); + + // Add recipients + for to in &email.to { + builder = builder.to(to.parse()?); + } + if let Some(cc) = &email.cc { + for c in cc { + builder = builder.cc(c.parse()?); + } + } + + // Add BCC recipients if present + if let Some(bcc) = &email.bcc { + for bc in bcc { + builder = builder.cc(bc.parse()?); + } + } + + // Add reply-to if present + if let Some(reply_to) = &email.reply_to { + for r in reply_to { + builder = builder.reply_to(r.parse()?); + } + } + if email.alternative_html.is_some() { + builder + .multipart(MultiPart::alternative_plain_html( + String::from(email.body.clone()), + String::from(email.alternative_html.clone().unwrap()), + )) + .map_err(|e| { + EmailError::MessageError(format!("Failed to create email message: {e}")) + }) + } else { + builder + .body(email.body.clone()) + .map_err(|e| EmailError::MessageError(format!("Failed email body:{e}"))) + } + } +} +/// Trait for sending emails using SMTP transport +/// This trait provides methods for testing connection, +/// sending a single email, and building the transport. +/// It is implemented for `SmtpTransport`. +/// This trait is useful for abstracting the email sending functionality +/// and allows for easier testing and mocking. +/// It can be used in applications that need to send emails +/// using SMTP protocol. +/// #Errors +/// `EmailError::ConnectionError` if there is an issue with the SMTP connection. +/// `EmailError::SendError` if there is an issue with sending the email. +/// `EmailError::ConfigurationError` if the SMTP configuration is invalid. +#[cfg_attr(test, automock)] +pub trait EmailTransport: Send + Sync { + /// Test the connection to the SMTP server. + /// # Errors + /// Returns Ok(true) if the connection is successful, otherwise + /// ``EmailError::ConnectionError``. + fn test_connection(&self) -> Result; + + /// Send an email message. + /// # Errors + /// Returns Ok(true) if the connection is successful, otherwise + /// ``EmailError::ConnectionError or SendError``. + fn send_email(&self, email: &Message) -> Result<()>; +} + +impl EmailTransport for SmtpTransport { + fn test_connection(&self) -> Result { + Ok(self.test_connection().is_ok()) + } + + fn send_email(&self, email: &Message) -> Result<()> { + // Call the actual Transport::send method + match self.send(email) { + Ok(_) => Ok(()), + Err(e) => Err(EmailError::SendError(e.to_string())), + } + } +} + +/// Trait representing an email backend for sending emails. +pub trait EmailBackend: Send + Sync + 'static { + /// Creates a new instance of the email backend with the given + /// configuration. + /// + /// # Arguments + /// + /// * `config` - The SMTP configuration to use. + fn new(config: SmtpConfig) -> Self; + + /// Initialize the backend for any specialization for any backend such as + /// `FileTransport` ``SmtpTransport`` + /// + /// # Errors + /// + /// `EmailError::ConfigurationError`: + /// If the SMTP configuration is invalid (e.g., missing required fields like + /// username and password). + /// If the host is empty or blank in the configuration. + /// If the credentials cannot be created from the configuration. + /// + /// `EmailError::ConnectionError`: + /// If the transport cannot be created for the specified mode (e.g., + /// invalid host or unsupported configuration). + /// If the transport fails to connect to the SMTP server. + fn init(&mut self) -> Result<()>; + + /// Open a connection to the SMTP server. + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with + /// resolving the SMTP host, + /// creating the TLS parameters, or connecting to the SMTP server. + fn open(&mut self) -> Result<&Self>; + /// Close the connection to the SMTP server. + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with + /// closing the SMTP connection. + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with + /// closing the SMTP connection. + fn close(&mut self) -> Result<()>; + + /// Send a single email message + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with + /// opening the SMTP connection, + /// building the email message, or sending the email. + fn send_message(&mut self, message: &EmailMessage) -> Result<()>; + + /// Send multiple email messages + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with + /// sending any of the emails. + fn send_messages(&mut self, emails: &[EmailMessage]) -> Result { + let mut sent_count = 0; + + for email in emails { + match self.send_message(email) { + Ok(()) => sent_count += 1, + Err(e) => return Err(e), + } + } + + Ok(sent_count) + } +} + +impl EmailBackend for SmtpEmailBackend { + /// Creates a new instance of `EmailBackend` with the given configuration. + /// + /// # Arguments + /// + /// * `config` - The SMTP configuration to use. + fn new(config: SmtpConfig) -> Self { + Self { + config, + transport: None, + debug: false, + transport_state: TransportState::Uninitialized, + } + } + + /// Safely initializes the SMTP transport based on the configured mode. + /// + /// This function validates the SMTP configuration and creates the + /// appropriate transport based on the mode (e.g., Localhost, + /// Unencrypted, Relay, or ``StartTlsRelay``). + /// It also sets the timeout, port, and credentials if provided. + /// + /// # Errors + /// + /// `EmailError::ConfigurationError`: + /// If the SMTP configuration is invalid (e.g., missing required fields + /// like username and password). + /// If the host is empty or blank in the configuration. + /// If the credentials cannot be created from the configuration. + /// + /// `EmailError::ConnectionError`: + /// If the transport cannot be created for the specified mode (e.g., + /// invalid host or unsupported configuration). + /// If the transport fails to connect to the SMTP server. + fn init(&mut self) -> Result<()> { + if self.transport_state == TransportState::Initialized { + return Ok(()); + } + self.config.validate().map_err(|e| { + EmailError::ConfigurationError(format!( + "Failed to validate SMTP configuration,error: {e}" + )) + })?; + let mut transport_builder = match &self.config.mode { + SmtpTransportMode::None => { + return Err(EmailError::ConfigurationError( + "SMTP transport mode is not specified".to_string(), + )); + } + SmtpTransportMode::Localhost => SmtpTransport::relay("localhost").map_err(|e| { + EmailError::ConnectionError(format!( + "Failed to create SMTP localhost transport,error: {e}" + )) + })?, + SmtpTransportMode::Unencrypted(host) => SmtpTransport::builder_dangerous(host), + SmtpTransportMode::Relay(host) => SmtpTransport::relay(host).map_err(|e| { + EmailError::ConnectionError(format!( + "Failed to create SMTP relay transport host:{host},error: {e}" + )) + })?, + SmtpTransportMode::StartTlsRelay(host) => { + SmtpTransport::starttls_relay(host).map_err(|e| { + EmailError::ConnectionError(format!( + "Failed to create SMTP tls_relay transport host:{host},error: {e}" + )) + })? + } + }; + // Set the timeout for the transport + transport_builder = transport_builder.timeout(self.config.timeout); + + // Set the port if provided in the configuration + // The port is optional, so we check if it's Some before setting it + // If the port is None, the default port for the transport will be used + if self.config.port.is_some() { + transport_builder = transport_builder.port(self.config.port.unwrap()); + } + + // Create the credentials using the provided configuration + let credentials = Credentials::try_from(&self.config).map_err(|e| { + EmailError::ConfigurationError(format!("Failed to create SMTP credentials,error: {e}")) + })?; + + // Add authentication if credentials provided + let transport = if self.config.username.is_some() && self.config.password.is_some() { + transport_builder.credentials(credentials).build() + } else { + transport_builder.build() + }; + self.transport = Some(Box::new(transport)); + self.transport_state = TransportState::Initialized; + Ok(()) + } + /// Opens a connection to the SMTP server or return the active connection. + /// + /// This method ensures that the SMTP transport is properly initialized and + /// tests the connection to the SMTP server. If the transport is already + /// initialized and the connection is working, it will reuse the existing + /// transport. Otherwise, it will initialize a new transport and test the + /// connection. + /// + /// # Errors + /// + /// This function can return the following errors: + /// + /// `EmailError::ConfigurationError`: + /// If the SMTP configuration is invalid (e.g., missing required fields + /// like username and password). + /// If the host is empty or blank in the configuration. + /// If the credentials cannot be created from the configuration. + /// + /// `EmailError::ConnectionError`: + /// If the transport cannot be created for the specified mode (e.g., + /// invalid host or unsupported configuration). + /// If the transport fails to connect to the SMTP server. + fn open(&mut self) -> Result<&Self> { + // Test if self.transport is None or if the connection is not working + if self.transport.is_some() && self.transport.as_ref().unwrap().test_connection().is_ok() { + return Ok(self); + } + // Initialize the transport + self.init()?; + // Test connection to the SMTP server + if self.transport.as_ref().unwrap().test_connection().is_err() { + return Err(EmailError::ConnectionError( + "Failed to connect to SMTP server".to_string(), + )); + } + Ok(self) + } + + /// Close the connection to the SMTP server + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with + /// closing the SMTP connection. + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with + /// closing the SMTP connection. + fn close(&mut self) -> Result<()> { + self.transport = None; + self.transport_state = TransportState::Uninitialized; + Ok(()) + } + + /// Send a single email message + /// + /// # Errors + /// + /// This function will return an `EmailError` if there is an issue with + /// opening the SMTP connection, + /// building the email message, or sending the email. + fn send_message(&mut self, email: &EmailMessage) -> Result<()> { + self.open()?; + if self.debug { + println!("Dump email: {email:#?}"); + } + // Send the email + self.transport + .as_ref() + .ok_or(EmailError::ConnectionError( + "SMTP transport is not initialized".to_string(), + ))? + .send_email(&email.try_into()?) + .map_err(|e| EmailError::SendError(e.to_string()))?; + + Ok(()) + } +} +impl SmtpEmailBackend { + /// Creates a new instance of `SmtpEmailBackend` from the given + /// configuration and transport. + /// + /// # Arguments + /// + /// * `config` - The SMTP configuration to use. + /// * `transport` - An optional transport to use for sending emails. + /// + /// # Returns + /// + /// A new instance of `SmtpEmailBackend`. + #[allow(clippy::must_use_candidate)] + pub fn from_config(config: SmtpConfig, transport: Box) -> Self { + Self { + config, + transport: Some(transport), + debug: false, + transport_state: TransportState::Uninitialized, + } + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_defaults_values() { + let config = SmtpConfig::default(); + + assert_eq!(config.mode, SmtpTransportMode::None); + assert_eq!(config.port, None); + assert_eq!(config.username, None); + assert_eq!(config.password, None); + assert_eq!(config.timeout, Some(Duration::from_secs(60))); + } + + #[test] + fn test_config_default_ok() { + let config = SmtpConfig::default(); + let result = config.validate(); + assert!(result.is_ok()); + } + #[test] + fn test_config_unencrypted_localhost_ok() { + let config = SmtpConfig::new(SmtpTransportMode::Unencrypted("localhost".to_string())); + let result = config.validate(); + assert!(result.is_ok()); + } + + #[test] + fn test_config_blankhost_unencrypted_ok() { + let config = SmtpConfig::new(SmtpTransportMode::Unencrypted(String::new())); + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + + #[test] + fn test_config_blankhost_relay_ok() { + let config = SmtpConfig::new(SmtpTransportMode::Relay(String::new())); + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + + #[test] + fn test_config_blankhost_starttls_ok() { + let config = SmtpConfig::new(SmtpTransportMode::StartTlsRelay(String::new())); + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + + #[test] + fn test_config_relay_password_failure() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), + username: Some("user@cotexample.com".to_string()), + ..Default::default() + }; + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + + #[test] + fn test_config_credentials_password_failure() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), + username: Some("user@cotexample.com".to_string()), + ..Default::default() + }; + let result = Credentials::try_from(&config); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + #[test] + fn test_config_credentials_username_failure() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), + password: Some("user@cotexample.com".to_string()), + ..Default::default() + }; + let result = Credentials::try_from(&config); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + + #[test] + fn test_config_credentials_ok() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), + username: Some("user@cotexample.com".to_string()), + password: Some("asdDSasd87".to_string()), + ..Default::default() + }; + let result = Credentials::try_from(&config); + assert!(result.is_ok()); + } + + #[test] + fn test_config_credentials_err() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("127.0.0.1".to_string()), + username: None, + password: None, + ..Default::default() + }; + let result = Credentials::try_from(&config); + assert!(result.is_ok()); + } + + #[test] + fn test_backend_config_ok() { + // Create the backend with our mock transport + let config = SmtpConfig::default(); + let backend = SmtpEmailBackend::new(config); + assert!(backend.transport.is_none()); + } + + #[test] + fn test_config_localhost_username_failure() { + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Localhost, + password: Some("asdDSasd87".to_string()), + ..Default::default() + }; + let result = config.validate(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + } + + #[test] + fn test_send_email() { + // Create a mock transport + let mut mock_transport = MockEmailTransport::new(); + + // Set expectations on the mock + // Expect test_connection to be called once and return Ok(true) + mock_transport + .expect_test_connection() + .times(1) + .returning(|| Ok(true)); + + // Expect send_email to be called once with any Message and return Ok(()) + mock_transport + .expect_send_email() + .times(1) + .returning(|_| Ok(())); + + // Create a simple email for testing + let email = EmailMessage { + subject: "Test Email".to_string(), + from: EmailAddress { + address: "from@cotexample.com".to_string(), + name: None, + }, + to: vec!["to@cotexample.com".to_string()], + body: "This is a test email sent from Rust.".to_string(), + ..Default::default() + }; + // Create SmtpConfig (the actual config doesn't matter as we're using a mock) + let config = SmtpConfig::default(); + + // Create the backend with our mock transport + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + // Try to send the email - this should succeed + let result = backend.send_message(&email); + + // Verify that the email was sent successfully + assert!(result.is_ok()); + } + + #[test] + fn test_send_email_send_ok() { + // Create a mock transport + let mut mock_transport = MockEmailTransport::new(); + + // Set expectations - test_connection succeeds but send_email fails + mock_transport + .expect_test_connection() + .times(1) + .returning(|| Ok(true)); + + mock_transport + .expect_send_email() + .times(1) + .returning(|_| Ok(())); + + // Create a simple email for testing + let email = EmailMessage { + subject: "Test Email".to_string(), + from: EmailAddress { + address: "from@cotexample.com".to_string(), + name: None, + }, + to: vec!["to@cotexample.com".to_string()], + body: "This is a test email #1.".to_string(), + ..Default::default() + }; + + // Create SmtpConfig (the actual config doesn't matter as we're using a mock) + let config = SmtpConfig::default(); + + // Create the backend with our mock transport + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + // Send the email - this should succeed with our mock + let result = backend.send_message(&email); + eprintln!("Result: {:?}", result); + + // Assert that the email was sent successfully + assert!(result.is_ok()); + } + + #[test] + fn test_backend_close() { + // Create a mock transport + let mock_transport = MockEmailTransport::new(); + let config = SmtpConfig::default(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + let result = backend.close(); + assert!(result.is_ok()); + } + + #[test] + fn test_send_email_send_failure() { + // Create a mock transport + let mut mock_transport = MockEmailTransport::new(); + + // Set expectations - test_connection succeeds but send_email fails + mock_transport + .expect_test_connection() + .times(1) + .returning(|| Ok(true)); + + mock_transport + .expect_send_email() + .times(1) + .returning(|_| Err(EmailError::SendError("Mock send failure".to_string()))); + + // Create a simple email for testing + let email = EmailMessage { + subject: "Test Email".to_string(), + from: String::from("from@cotexample.com").into(), + to: vec!["to@cotexample.com".to_string()], + cc: Some(vec!["cc@cotexample.com".to_string()]), + bcc: Some(vec!["bcc@cotexample.com".to_string()]), + reply_to: Some(vec!["anonymous@cotexample.com".to_string()]), + body: "This is a test email sent from Rust.".to_string(), + alternative_html: Some("This is a test email sent from Rust as HTML.".to_string()), + }; + + // Create the backend with our mock transport + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("invalid-host".to_string()), + port: Some(587), + username: Some("user@cotexample.com".to_string()), + ..Default::default() + }; + + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + // Try to send the email - this should fail + let result = backend.send_message(&email); + eprintln!("Result: {:?}", result); + + // Verify that we got a send error + assert!(matches!(result, Err(EmailError::SendError(_)))); + } + + #[test] + fn test_send_multiple_emails() { + // Create a mock transport + let mut mock_transport = MockEmailTransport::new(); + + // Set expectations - test_connection succeeds and send_email succeeds for both + // emails + mock_transport + .expect_test_connection() + .times(1..) + .returning(|| Ok(true)); + + mock_transport + .expect_send_email() + .times(2) + .returning(|_| Ok(())); + + // Create test emails + let emails = vec![ + EmailMessage { + subject: "Test Email".to_string(), + from: String::from("from@cotexample.com").into(), + to: vec!["to@cotexample.com".to_string()], + body: "This is a test email #1.".to_string(), + ..Default::default() + }, + EmailMessage { + subject: "Test Email".to_string(), + from: String::from("from@cotexample.com").into(), + to: vec!["to@cotexample.com".to_string()], + body: "This is a test email #2.".to_string(), + ..Default::default() + }, + ]; + + // Create the backend with our mock transport + let config = SmtpConfig::default(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + // Send the emails + let result = backend.send_messages(&emails); + + // Verify that both emails were sent successfully + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 2); + } + + // An integration test to send an email to localhost using the default + // configuration. Dependent on the mail server running on localhost, this + // test may fail/hang if the server is not available. + #[test] + #[ignore] + fn test_send_email_localhost() { + // Create a test email + let email = EmailMessage { + subject: "Test Email".to_string(), + from: String::from("from@cotexample.com").into(), + to: vec!["to@cotexample.com".to_string()], + cc: Some(vec!["cc@cotexample.com".to_string()]), + bcc: Some(vec!["bcc@cotexample.com".to_string()]), + reply_to: Some(vec!["anonymous@cotexample.com".to_string()]), + body: "This is a test email sent from Rust.".to_string(), + alternative_html: Some("This is a test email sent from Rust as HTML.".to_string()), + }; + + // Get the port it's running on + let port = 1025; //Mailhog default smtp port + let config = SmtpConfig { + mode: SmtpTransportMode::Unencrypted("localhost".to_string()), + port: Some(port), + ..Default::default() + }; + // Create a new email backend + let mut backend = SmtpEmailBackend::new(config); + + let result = backend.send_message(&email); + assert!(result.is_ok()); + } + #[test] + fn test_open_method_with_existing_working_transport() { + // Create a mock transport that will pass connection test + let mut mock_transport = MockEmailTransport::new(); + mock_transport + .expect_test_connection() + .times(2) + .returning(|| Ok(true)); + + // Create config and backend + let config = SmtpConfig::default(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + + // First open should succeed + let result = backend.open(); + assert!(result.is_ok()); + + // Second open should also succeed without reinitializing + let result = backend.open(); + assert!(result.is_ok()); + } + + #[test] + fn test_open_method_with_failed_connection() { + // Create a mock transport that will fail connection test + let mut mock_transport = MockEmailTransport::new(); + mock_transport + .expect_test_connection() + .times(1) + .returning(|| { + Err(EmailError::ConnectionError( + "Mock connection failure".to_string(), + )) + }); + // Mock the from_config method to return a transport + // Create config and backend + let config = SmtpConfig::default(); + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + // Open should fail due to connection error + let result = backend.open(); + assert!(result.is_err()); + assert!(backend.transport_state == TransportState::Uninitialized); + } + + #[test] + fn test_init_only_username_connection() { + // Create a mock transport that will fail connection test + let mock_transport = MockEmailTransport::new(); + // Mock the from_config method to return a transport + // Create config and backend + let config = SmtpConfig { + mode: SmtpTransportMode::Unencrypted("localhost".to_string()), + username: Some("justtheruser".to_string()), + ..Default::default() + }; + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + assert!(backend.transport_state == TransportState::Uninitialized); + let result = backend.init(); + assert!(matches!(result, Err(EmailError::ConfigurationError(_)))); + assert!(backend.transport_state == TransportState::Uninitialized); + } + + #[test] + fn test_init_ok_unencrypted_connection() { + // Create a mock transport that will fail connection test + let mock_transport = MockEmailTransport::new(); + // Create config and backend + let config = SmtpConfig { + mode: SmtpTransportMode::Unencrypted("localhost".to_string()), + ..Default::default() + }; + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + assert!(backend.transport_state == TransportState::Uninitialized); + let result = backend.init(); + assert!(result.is_ok()); + assert!(backend.transport_state == TransportState::Initialized); + } + + #[test] + fn test_init_with_relay_credentials() { + // Create a mock transport that will fail connection test + let mock_transport = MockEmailTransport::new(); + // Mock the from_config method to return a transport + // Create config and backend + let config = SmtpConfig { + mode: SmtpTransportMode::Relay("localhost".to_string()), + username: Some("justtheruser".to_string()), + password: Some("asdf877DF".to_string()), + port: Some(25), + ..Default::default() + }; + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + // Open should fail due to connection error + assert!(backend.transport_state == TransportState::Uninitialized); + let result = backend.init(); + assert!(result.is_ok()); + assert!(backend.transport_state == TransportState::Initialized); + } + + #[test] + fn test_init_with_tlsrelay_credentials() { + // Create a mock transport that will fail connection test + let mock_transport = MockEmailTransport::new(); + // Mock the from_config method to return a transport + // Create config and backend + let config = SmtpConfig { + mode: SmtpTransportMode::StartTlsRelay("junkyhost".to_string()), + username: Some("justtheruser".to_string()), + password: Some("asdf877DF".to_string()), + ..Default::default() + }; + let mut backend = SmtpEmailBackend::from_config(config, Box::new(mock_transport)); + assert!(backend.transport_state == TransportState::Uninitialized); + let result = backend.init(); + assert!(result.is_ok()); + assert!(backend.transport_state == TransportState::Initialized); + } + + #[test] + fn test_email_error_variants() { + let message_error = EmailError::MessageError("Invalid message".to_string()); + assert_eq!(format!("{message_error}"), "Message error: Invalid message"); + + let config_error = EmailError::ConfigurationError("Invalid config".to_string()); + assert_eq!( + format!("{config_error}"), + "Invalid email configuration: Invalid config" + ); + + let send_error = EmailError::SendError("Failed to send".to_string()); + assert_eq!(format!("{send_error}"), "Send error: Failed to send"); + + let connection_error = EmailError::ConnectionError("Failed to connect".to_string()); + assert_eq!( + format!("{connection_error}"), + "Connection error: Failed to connect" + ); + } +} diff --git a/cot/src/lib.rs b/cot/src/lib.rs index ced0bbc3..553b9d33 100644 --- a/cot/src/lib.rs +++ b/cot/src/lib.rs @@ -68,6 +68,7 @@ mod body; pub mod cli; pub mod common_types; pub mod config; +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 4c356509..5d92d386 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -23,7 +23,7 @@ use std::future::poll_fn; use std::panic::AssertUnwindSafe; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use axum::handler::HandlerWithoutStateExt; @@ -40,11 +40,12 @@ use crate::auth::{AuthBackend, NoAuthBackend}; use crate::cli::Cli; #[cfg(feature = "db")] use crate::config::DatabaseConfig; -use crate::config::{AuthBackendConfig, ProjectConfig}; +use crate::config::{AuthBackendConfig, EmailBackendConfig, EmailBackendType, ProjectConfig}; #[cfg(feature = "db")] use crate::db::Database; #[cfg(feature = "db")] use crate::db::migrations::{MigrationEngine, SyncDynMigration}; +use crate::email::{EmailBackend, SmtpConfig, SmtpEmailBackend}; use crate::error::ErrorRepr; use crate::error_page::{Diagnostics, ErrorPageTrigger}; use crate::handler::BoxedHandler; @@ -1237,7 +1238,10 @@ impl Bootstrapper { let handler = self.project.middlewares(handler, &self.context); let auth_backend = self.project.auth_backend(&self.context); - let context = self.context.with_auth(auth_backend); + let email_backend = Self::init_email_backend(&self.context.config.email_backend).await; + let context = self + .context + .with_auth_and_email(auth_backend, email_backend); Ok(Bootstrapper { project: self.project, @@ -1245,8 +1249,26 @@ impl Bootstrapper { handler, }) } -} + async fn init_email_backend( + config: &EmailBackendConfig, + ) -> Option>> { + match &config.backend_type { + EmailBackendType::None => None, + EmailBackendType::Smtp => { + let smtp_config = SmtpConfig { + mode: config.smtp_mode.clone(), + port: config.port, + username: config.username.clone(), + password: config.password.clone(), + timeout: config.timeout, + }; + let backend = SmtpEmailBackend::new(smtp_config); + Some(Arc::new(Mutex::new(backend))) + } + } + } +} impl Bootstrapper { /// Returns the context and handler of the bootstrapper. /// @@ -1292,7 +1314,8 @@ mod sealed { /// 2. [`WithConfig`] /// 3. [`WithApps`] /// 4. [`WithDatabase`] -/// 5. [`Initialized`] +/// 5. [`WithEmail`] +/// 6. [`Initialized`] /// /// # Sealed /// @@ -1339,6 +1362,8 @@ pub trait BootstrapPhase: sealed::Sealed { type Database: Debug; /// The type of the auth backend. type AuthBackend; + /// The type of the email backend. + type EmailBackend: Debug; } /// First phase of bootstrapping a Cot project, the uninitialized phase. @@ -1359,6 +1384,7 @@ impl BootstrapPhase for Uninitialized { #[cfg(feature = "db")] type Database = (); type AuthBackend = (); + type EmailBackend = (); } /// Second phase of bootstrapping a Cot project, the with-config phase. @@ -1379,6 +1405,7 @@ impl BootstrapPhase for WithConfig { #[cfg(feature = "db")] type Database = (); type AuthBackend = (); + type EmailBackend = (); } /// Third phase of bootstrapping a Cot project, the with-apps phase. @@ -1399,6 +1426,7 @@ impl BootstrapPhase for WithApps { #[cfg(feature = "db")] type Database = (); type AuthBackend = (); + type EmailBackend = (); } /// Fourth phase of bootstrapping a Cot project, the with-database phase. @@ -1419,6 +1447,7 @@ impl BootstrapPhase for WithDatabase { #[cfg(feature = "db")] type Database = Option>; type AuthBackend = ::AuthBackend; + type EmailBackend = ::EmailBackend; } /// The final phase of bootstrapping a Cot project, the initialized phase. @@ -1439,6 +1468,7 @@ impl BootstrapPhase for Initialized { #[cfg(feature = "db")] type Database = ::Database; type AuthBackend = Arc; + type EmailBackend = Option>>; } /// Shared context and configs for all apps. Used in conjunction with the @@ -1453,6 +1483,8 @@ pub struct ProjectContext { database: S::Database, #[debug("..")] auth_backend: S::AuthBackend, + #[debug("..")] + email_backend: S::EmailBackend, } impl ProjectContext { @@ -1465,6 +1497,7 @@ impl ProjectContext { #[cfg(feature = "db")] database: (), auth_backend: (), + email_backend: (), } } @@ -1476,6 +1509,7 @@ impl ProjectContext { #[cfg(feature = "db")] database: self.database, auth_backend: self.auth_backend, + email_backend: self.email_backend, } } } @@ -1516,6 +1550,7 @@ impl ProjectContext { #[cfg(feature = "db")] database: self.database, auth_backend: self.auth_backend, + email_backend: self.email_backend, } } } @@ -1555,13 +1590,18 @@ impl ProjectContext { #[cfg(feature = "db")] database, auth_backend: self.auth_backend, + email_backend: self.email_backend, } } } impl ProjectContext { #[must_use] - fn with_auth(self, auth_backend: Arc) -> ProjectContext { + fn with_auth_and_email( + self, + auth_backend: Arc, + email_backend: Option>>, + ) -> ProjectContext { ProjectContext { config: self.config, apps: self.apps, @@ -1569,10 +1609,10 @@ impl ProjectContext { auth_backend, #[cfg(feature = "db")] database: self.database, + email_backend, } } } - impl ProjectContext { #[cfg(feature = "test")] pub(crate) fn initialized( @@ -1581,6 +1621,7 @@ impl ProjectContext { router: ::Router, auth_backend: ::AuthBackend, #[cfg(feature = "db")] database: ::Database, + email_backend: ::EmailBackend, ) -> Self { Self { config, @@ -1589,6 +1630,7 @@ impl ProjectContext { #[cfg(feature = "db")] database, auth_backend, + email_backend, } } } @@ -1695,6 +1737,38 @@ impl>>> ProjectContext { ) } } +impl>>>> ProjectContext { + /// Returns the email backend for the project, if it is enabled. + /// + /// # Examples + /// + /// ``` + /// use cot::request::{Request, RequestExt}; + /// use cot::response::Response; + /// + /// async fn index(request: Request) -> cot::Result { + /// let email_backend = request.context().try_email_backend(); + /// if let Some(email_backend) = email_backend { + /// // do something with the email backend + /// } else { + /// // email backend is not enabled + /// } + /// # todo!() + /// } + /// ``` + #[must_use] + pub fn try_email_backend(&self) -> Option<&Arc>> { + self.email_backend.as_ref() + } + /// Returns the email backend for the project, if it is enabled. + #[must_use] + #[track_caller] + pub fn email_backend(&self) -> &Arc> { + self.try_email_backend().expect( + "Email backend missing. Did you forget to add the email backend when configuring CotProject?", + ) + } +} /// Runs the Cot project on the given address. /// diff --git a/cot/src/test.rs b/cot/src/test.rs index 22458abc..44ffefc0 100644 --- a/cot/src/test.rs +++ b/cot/src/test.rs @@ -750,6 +750,7 @@ impl TestRequestBuilder { auth_backend, #[cfg(feature = "db")] self.database.clone(), + None, ); prepare_request(&mut request, Arc::new(context)); diff --git a/examples/send-email/Cargo.toml b/examples/send-email/Cargo.toml new file mode 100644 index 00000000..1a2d990b --- /dev/null +++ b/examples/send-email/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "send-email" +version = "0.1.0" +publish = false +description = "Send email - Cot example." +edition = "2021" + +[dependencies] +cot = { path = "../../cot" } +lettre = { version = "0.11.15", features = ["native-tls"] } diff --git a/examples/send-email/config/dev.toml b/examples/send-email/config/dev.toml new file mode 100644 index 00000000..8375b5e8 --- /dev/null +++ b/examples/send-email/config/dev.toml @@ -0,0 +1,5 @@ +[email_backend] +backend_type = "smtp" +smtp_mode = "encrypted" +host = "localhost" +port = 1025 diff --git a/examples/send-email/src/main.rs b/examples/send-email/src/main.rs new file mode 100644 index 00000000..15f40158 --- /dev/null +++ b/examples/send-email/src/main.rs @@ -0,0 +1,114 @@ +use cot::cli::CliMetadata; +use cot::config::{DatabaseConfig, EmailBackendConfig, EmailBackendType, ProjectConfig}; +use cot::email::{EmailBackend, EmailMessage, SmtpTransportMode}; +use cot::form::Form; +use cot::project::RegisterAppsContext; +use cot::request::{Request, RequestExt}; +use cot::response::{Response, ResponseExt}; +use cot::router::{Route, Router}; +use cot::{App, AppBuilder, Body, Project, StatusCode}; + +struct EmailApp; + +impl App for EmailApp { + fn name(&self) -> &str { + "email" + } + + fn router(&self) -> Router { + Router::with_urls([ + Route::with_handler_and_name("/", email_form, "email_form"), + Route::with_handler_and_name("/send", send_email, "send_email"), + ]) + } +} + +async fn email_form(_request: Request) -> cot::Result { + let template = include_str!("../templates/index.html"); + Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) +} +#[derive(Debug, Form)] +struct EmailForm { + from: String, + to: String, + subject: String, + body: String, +} +async fn send_email(mut request: Request) -> cot::Result { + let form = EmailForm::from_request(&mut request).await?.unwrap(); + + let from = form.from; + let to = form.to; + let subject = form.subject; + let body = form.body; + + // Create the email + let email = EmailMessage { + subject, + from: from.into(), + to: vec![to], + body, + alternative_html: None, + ..Default::default() + }; + let _database = request.context().database(); + let email_backend = request.context().email_backend(); + let backend_clone = email_backend.clone(); + { + let backend = &backend_clone; + let _x = backend.lock().unwrap().send_message(&email); + } + // let template = include_str!("../templates/sent.html"); + // Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) + let template = include_str!("../templates/sent.html"); + + Ok(Response::new_html(StatusCode::OK, Body::fixed(template))) +} +struct MyProject; +impl Project for MyProject { + fn cli_metadata(&self) -> CliMetadata { + cot::cli::metadata!() + } + + fn config(&self, _config_name: &str) -> cot::Result { + // Create the email backend + // let config = ProjectConfig::from_toml( + // r#" + // [database] + // url = "sqlite::memory:" + + // [email_backend] + // backend_type = "Smtp" + // smtp_mode = "Localhost" + // port = 1025 + // "#, + // )?; + let mut email_config = EmailBackendConfig::builder(); + email_config.backend_type(EmailBackendType::Smtp); + email_config.smtp_mode(SmtpTransportMode::Localhost); + email_config.port(1025_u16); + let config = ProjectConfig::builder() + .debug(true) + .database(DatabaseConfig::builder().url("sqlite::memory:").build()) + .email_backend(email_config.build()) + .build(); + Ok(config) + } + 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, + ) -> cot::BoxedHandler { + // context.config().email_backend().unwrap(); + handler.build() + } +} + +#[cot::main] +fn main() -> impl Project { + MyProject +} diff --git a/examples/send-email/templates/index.html b/examples/send-email/templates/index.html new file mode 100644 index 00000000..134515e3 --- /dev/null +++ b/examples/send-email/templates/index.html @@ -0,0 +1,28 @@ + + + + Send Email + + +

Send Email

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + diff --git a/examples/send-email/templates/sent.html b/examples/send-email/templates/sent.html new file mode 100644 index 00000000..c4e3faa0 --- /dev/null +++ b/examples/send-email/templates/sent.html @@ -0,0 +1,11 @@ + + + + Email Sent + + +

Email Sent Successfully

+

The email has been sent successfully.

+ Send another email + +