diff --git a/email_banner.hbs b/email_banner.hbs new file mode 100644 index 0000000..325e835 --- /dev/null +++ b/email_banner.hbs @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/sample_config.json b/sample_config.json index f86fba6..e9eceb9 100644 --- a/sample_config.json +++ b/sample_config.json @@ -17,5 +17,10 @@ }, "oidc_signing_key": "../test_oidc_key.pem", "oidc_public_key": "../test_oidc_key.pem.pub", - "oidc_issuer": "http://localhost:2521" + "oidc_issuer": "http://localhost:2521", + "email": { + "smtp": "smtp-relay.gmail.com", + "from": "Wilford ", + "banner_file": "../email_banner.hbs" + } } \ No newline at end of file diff --git a/sample_config_docker.json b/sample_config_docker.json index a1c54bc..d61dc72 100644 --- a/sample_config_docker.json +++ b/sample_config_docker.json @@ -17,5 +17,10 @@ }, "oidc_signing_key": "/test_oidc_key.pem", "oidc_public_key": "/test_oidc_key.pem.pub", - "oidc_issuer": "http://localhost:2521" + "oidc_issuer": "http://localhost:2521", + "email": { + "smtp": "smtp-relay.gmail.com", + "from": "Wilford ", + "banner_file": "../email_banner.hbs" + } } \ No newline at end of file diff --git a/server/Cargo.lock b/server/Cargo.lock index a6e0aea..ab4f980 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -142,7 +142,7 @@ dependencies = [ "actix-utils", "futures-core", "futures-util", - "mio", + "mio 0.8.11", "socket2", "tokio", "tracing", @@ -297,6 +297,17 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "atoi" version = "2.0.0" @@ -486,6 +497,22 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -722,6 +749,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -782,6 +820,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3d894bbbab314476b265f9b2d46bf24b123a36dd0e96b06a1b49545b9d9dcc" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.34" @@ -933,9 +987,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" @@ -961,15 +1015,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -978,21 +1032,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-io", @@ -1065,6 +1119,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "handlebars" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd4ccde012831f9a071a637b0d4e31df31c0f6c525784b35ae76a9ac6bc1e315" +dependencies = [ + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 1.0.60", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1090,12 +1159,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - [[package]] name = "hex" version = "0.4.3" @@ -1222,7 +1285,125 @@ dependencies = [ "hyper", "rustls 0.21.12", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "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 2.0.93", ] [[package]] @@ -1235,6 +1416,49 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_directory" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc51bf21d9c8c76d0d55b3926add7fde9b595719ee0d5710d46f8ee66131cca9" +dependencies = [ + "include_directory_macros", + "mime", +] + +[[package]] +name = "include_directory_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35011b5de7391d94ea631aa584d09a88b2e877505a614e4952970214f2fd1b90" +dependencies = [ + "mime", + "new_mime_guess", + "proc-macro2", + "quote", +] + [[package]] name = "indenter" version = "0.3.3" @@ -1354,11 +1578,41 @@ dependencies = [ "spin 0.5.2", ] +[[package]] +name = "lettre" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab4c9a167ff73df98a5ecc07e8bf5ce90b583665da3d1762eb1f775ad4d0d6f5" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna 1.0.3", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls 0.23.20", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "socket2", + "tokio", + "tokio-rustls 0.26.1", + "url", + "webpki-roots 0.26.7", +] + [[package]] name = "libc" -version = "0.2.154" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "libm" @@ -1383,6 +1637,12 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + [[package]] name = "local-channel" version = "0.1.5" @@ -1416,6 +1676,21 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "mailer" +version = "0.1.0" +dependencies = [ + "futures-util", + "handlebars", + "include_directory", + "lettre", + "nix", + "serde", + "thiserror 2.0.9", + "tokio", + "tracing", +] + [[package]] name = "matchers" version = "0.1.0" @@ -1441,6 +1716,15 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1474,12 +1758,46 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.52.0", +] + [[package]] name = "mutually_exclusive_features" version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d02c0b00610773bb7fc61d85e13d86c7858cbdf00e1a120bfc41bc055dbaa0e" +[[package]] +name = "new_mime_guess" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a2dfb3559d53e90b709376af1c379462f7fb3085a0177deb73e6ea0d99eff4" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.5.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "noiseless-tracing-actix-web" version = "0.1.0" @@ -1555,23 +1873,28 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "num-modular" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" dependencies = [ - "autocfg", - "libm", + "num-modular", ] [[package]] -name = "num_cpus" -version = "1.16.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "hermit-abi", - "libc", + "autocfg", + "libm", ] [[package]] @@ -1685,6 +2008,51 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +dependencies = [ + "memchr", + "thiserror 2.0.9", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.93", +] + +[[package]] +name = "pest_meta" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -1783,6 +2151,15 @@ dependencies = [ "bytes", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "quote" version = "1.0.36" @@ -1792,6 +2169,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 = "rand" version = "0.8.5" @@ -1915,7 +2298,7 @@ dependencies = [ "sync_wrapper", "system-configuration", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", @@ -2017,6 +2400,7 @@ version = "0.23.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -2114,18 +2498,18 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.202" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.202" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ "proc-macro2", "quote", @@ -2483,6 +2867,25 @@ dependencies = [ "url", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stacker" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799c883d55abdb5e98af1a7b3f23b9b6de8ecada0ecac058672d7635eb48ca7b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.52.0", +] + [[package]] name = "stringprep" version = "0.1.4" @@ -2541,6 +2944,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -2661,6 +3075,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2678,28 +3102,27 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.37.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", "libc", - "mio", - "num_cpus", + "mio 1.0.3", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", @@ -2716,6 +3139,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +dependencies = [ + "rustls 0.23.20", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.15" @@ -2748,9 +3181,9 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -2773,9 +3206,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", @@ -2784,9 +3217,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -2843,6 +3276,18 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -2883,7 +3328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", ] @@ -2893,6 +3338,18 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.8.0" @@ -3065,6 +3522,7 @@ dependencies = [ "database", "envy", "espocrm-rs", + "mailer", "noiseless-tracing-actix-web", "pem", "rand", @@ -3253,6 +3711,42 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.34" @@ -3273,12 +3767,55 @@ dependencies = [ "syn 2.0.93", ] +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", + "synstructure", +] + [[package]] name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "zstd" version = "0.13.1" diff --git a/server/Cargo.toml b/server/Cargo.toml index ba11096..37fc16d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" members = [ - "database", + "database", "mailer", "wilford", ] diff --git a/server/mailer/Cargo.toml b/server/mailer/Cargo.toml new file mode 100644 index 0000000..db2f1db --- /dev/null +++ b/server/mailer/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "mailer" +version = "0.1.0" +edition = "2021" + +[dependencies] +thiserror = "2.0.9" +handlebars = { version = "6.2.0" } +lettre = { version = "0.11.11", features = ["smtp-transport", "tokio1-rustls-tls", "builder"], default-features = false } +nix = { version = "0.29.0", features = ["net"] } +tokio = { version = "1.42.0", features = ["net", "time", "test-util", "macros"] } +futures-util = "0.3.31" +tracing = "0.1.41" +serde = { version = "1.0.217", features = ["derive"] } +include_directory = "0.1.1" \ No newline at end of file diff --git a/server/mailer/build.rs b/server/mailer/build.rs new file mode 100644 index 0000000..0f1dba7 --- /dev/null +++ b/server/mailer/build.rs @@ -0,0 +1,4 @@ +fn main() { + println!("cargo:rerun-if-changed=templates"); + println!("cargo:rerun-if-changed=partials"); +} diff --git a/server/mailer/partials/header.hbs b/server/mailer/partials/header.hbs new file mode 100644 index 0000000..fe709b6 --- /dev/null +++ b/server/mailer/partials/header.hbs @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/server/mailer/src/conn.rs b/server/mailer/src/conn.rs new file mode 100644 index 0000000..f20b4e3 --- /dev/null +++ b/server/mailer/src/conn.rs @@ -0,0 +1,49 @@ +use crate::error::{MailerError, Result}; +use lettre::transport::smtp::client::{AsyncSmtpConnection, TlsParameters}; +use lettre::transport::smtp::extension::ClientId; +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; +use tracing::{debug, error, trace}; + +/// Get an SMTP connection. +/// +/// # Errors +/// - If the connection failed. +/// - If establishing the TLS connection failed. +pub async fn get_connection( + addr: Ipv4Addr, + smtp_server: &str, + ehlo_domain: &str, +) -> Result { + let client_id = ClientId::Domain(ehlo_domain.to_string()); + + trace!("Opening SMTP connection"); + let mut conn = AsyncSmtpConnection::connect_tokio1( + (smtp_server, 587), + Some(Duration::from_secs(3)), + &client_id, + // We cannot do STARTTLS (which uses port 465, which is blocked by Hetzner), so use port 587 + // Port 587 starts out with regular SMTP commands, after the EHLO we upgrade to STARTTLS + None, + Some(IpAddr::V4(addr)), + ) + .await?; + + // If we can upgrade to STARTLS, do so + if conn.can_starttls() { + conn.starttls( + TlsParameters::new_rustls(smtp_server.to_string())?, + &client_id, + ) + .await?; + } + + trace!("Checking SMTP connection"); + if conn.test_connected().await { + debug!("SMTP connection OK"); + Ok(conn) + } else { + error!("Could not connect to server (SMTP)"); + Err(MailerError::SmtpConnect) + } +} diff --git a/server/mailer/src/email/mod.rs b/server/mailer/src/email/mod.rs new file mode 100644 index 0000000..bb833b3 --- /dev/null +++ b/server/mailer/src/email/mod.rs @@ -0,0 +1,224 @@ +mod password_forgotten; + +pub use password_forgotten::*; +use std::future::Future; + +use crate::error::Result; +use handlebars::Handlebars; +use include_directory::{include_directory, Dir}; +use lettre::message::{Mailbox, MessageBuilder, SinglePart}; +use lettre::transport::smtp::client::AsyncSmtpConnection; +use lettre::Message; +use serde::Serialize; +use std::str::FromStr; + +/// Contents of the `partials` directory. +const PARTIALS: Dir<'_> = include_directory!("mailer/partials"); +/// Contents of the `templates` directory +const TEMPLATES: Dir<'_> = include_directory!("mailer/templates/"); + +/// Language of the email +pub enum Locale { + Nl, + En, +} + +/// A handlebars template +pub struct HbsTemplate { + /// The name of the template + pub name: String, + /// The value of the template in Handlebars + pub content: String, +} + +/// An email that can be sent. +pub trait Mailable { + type Data: Serialize + Send + Sync; + + /// Send an email. + /// Extra partials can be used for runtime defined partials, e.g. for a banner logo. + /// + /// # Errors + /// - If the `to` or `from` addresses are invalid + /// - If the body could not be rendered + /// - If the email could not be sent + /// - The template does not exist + // While `async` trait functions are a thing, the compiler discourages it, + // when using the automatic desugaring (using the `async` keyword, we cannot specify + // that the future is Send + Sync, which makes life harder for the callee. + fn send( + connection: &mut AsyncSmtpConnection, + to: &str, + from: &str, + data: &Self::Data, + locale: Locale, + extra_partials: Vec, + ) -> impl Future> + Send + Sync { + async { + Mailer::send( + connection, + to, + from, + Self::subject(&locale), + data, + Self::template_name(), + locale, + extra_partials, + ) + .await + } + } + + /// The name of the template used by this E-mail + fn template_name() -> &'static str; + + /// The subject of the email in the correct locale + fn subject(locale: &Locale) -> &'static str; +} + +struct Mailer; + +impl Mailer { + /// Send an email + /// + /// # Errors + /// - If the `to` or `from` addresses are invalid + /// - If the body could not be rendered + /// - If the email could not be sent + async fn send( + connection: &mut AsyncSmtpConnection, + to: &str, + from: &str, + subject: &str, + data: &S, + template_name: &str, + locale: Locale, + extra_partials: Vec, + ) -> Result<()> { + // Render the body + let handlebars = Self::new_handlebars(extra_partials)?; + let body_html = + handlebars.render(&Self::format_locale_name(template_name, locale), data)?; + + // Create the message + let msg = + Self::prepare_message(to, from, subject)?.singlepart(SinglePart::html(body_html))?; + + // Send the message + connection.send(msg.envelope(), &msg.formatted()).await?; + + Ok(()) + } + + /// Format the name of the template based on the locale. + /// E.g. the template name `foo` becomes `foo.nl` if the locale is [Locale::Nl]. + fn format_locale_name(template_name: &str, locale: Locale) -> String { + let locale = match locale { + Locale::Nl => "nl", + Locale::En => "en", + }; + + format!("{template_name}.{locale}") + } + + /// Create the message with a sender, recipient and subject + /// + /// # Erorrs + /// - If the `to` or `from` addresses are invalid + fn prepare_message(to: &str, from: &str, subject: &str) -> Result { + let to = Mailbox::from_str(to)?; + let from = Mailbox::from_str(from)?; + + let msg = Message::builder().to(to).from(from).subject(subject); + + Ok(msg) + } + + /// Set up the Handlebars engine. + /// + /// # Errors + /// - If a partial is invalid. + /// - If a template is invalid. + fn new_handlebars(extra_partials: Vec) -> Result> { + let mut handlebars = Handlebars::new(); + handlebars.set_dev_mode(is_dev_mode()); + handlebars.set_strict_mode(true); + + // Register partials + for template in Self::partials() { + handlebars.register_partial(&template.name, template.content)?; + } + + // Register the extra partials (runtime defined) + for template in extra_partials { + handlebars.register_partial(&template.name, template.content)?; + } + + // Register templates + for template in Self::templates() { + handlebars.register_template_string(&template.name, template.content)?; + } + + Ok(handlebars) + } + + /// Get all partials stored in the binary. + /// Returns a tuple of (name, content). + fn partials() -> Vec { + Self::get_embed(PARTIALS) + } + + /// Get all templates stored in the binary. + /// Returns a tuple of (name, content). + fn templates() -> Vec { + Self::get_embed(TEMPLATES) + } + + /// Get all files in an embedded direcotry. + /// Returns a tuple of (name, content). + fn get_embed(embed: Dir<'_>) -> Vec { + embed + .files() + .into_iter() + .map(|f| { + let name = f + .path() + .file_name() + .map(|fname| fname.to_str()) + .flatten() + // Split by . + .map(|fname| fname.split(".").collect::>()) + // Keep all but the last element + .map(|parts| all_but_last(parts.into_iter())) + // Re-join string + .map(|parts: Vec<&str>| parts.join(".")) + .map(|fname| fname.to_string()); + + let contents = f.contents_utf8().map(|cnt| cnt.to_string()); + + match (name, contents) { + (Some(n), Some(c)) => Some(HbsTemplate { + name: n, + content: c, + }), + _ => None, + } + }) + .filter_map(|hbs_template| hbs_template) + .collect() + } +} + +/// Whether the program is compiled in debug mode +fn is_dev_mode() -> bool { + cfg!(debug_assertions) +} + +/// Keep all elements except the last element +fn all_but_last(iter: I) -> B +where + B: FromIterator, + I: DoubleEndedIterator + ExactSizeIterator, +{ + iter.rev().skip(1).rev().collect() +} diff --git a/server/mailer/src/email/password_forgotten.rs b/server/mailer/src/email/password_forgotten.rs new file mode 100644 index 0000000..cdc002a --- /dev/null +++ b/server/mailer/src/email/password_forgotten.rs @@ -0,0 +1,49 @@ +use crate::email::{Locale, Mailable}; +use serde::Serialize; + +pub struct PasswordForgottenEmail; + +#[derive(Serialize)] +pub struct PasswordForgottenData { + pub name: String, + pub temporary_password: String, +} + +impl Mailable for PasswordForgottenEmail { + type Data = PasswordForgottenData; + + fn template_name() -> &'static str { + "password_forgotten" + } + + fn subject(locale: &Locale) -> &'static str { + match locale { + Locale::Nl => "Tijdelijk wachtwoord", + Locale::En => "Temporary password", + } + } +} + +#[cfg(test)] +mod test { + use crate::email::password_forgotten::{PasswordForgottenData, PasswordForgottenEmail}; + use crate::email::{Locale, Mailable}; + use crate::test::{banner_partial, connection}; + + #[tokio::test] + async fn password_forgotten() { + PasswordForgottenEmail::send( + &mut connection().await, + "t.debruijn@array21.dev", + "t.debruijn@array21.dev", + &PasswordForgottenData { + name: "Tobias".to_string(), + temporary_password: "foobar".to_string(), + }, + Locale::Nl, + vec![banner_partial()], + ) + .await + .unwrap(); + } +} diff --git a/server/mailer/src/error.rs b/server/mailer/src/error.rs new file mode 100644 index 0000000..87f46a5 --- /dev/null +++ b/server/mailer/src/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum MailerError { + #[error("No IPv4 address could be found")] + NoIpv4, + #[error("Could not look up addresses: {0}")] + GetAddr(nix::errno::Errno), + #[error("Could not connect with SMTP server")] + SmtpConnect, + #[error(transparent)] + Transport(#[from] lettre::transport::smtp::Error), + #[error(transparent)] + Template(#[from] handlebars::TemplateError), + #[error(transparent)] + Render(#[from] handlebars::RenderError), + #[error(transparent)] + Email(#[from] lettre::error::Error), + #[error(transparent)] + Address(#[from] lettre::address::AddressError), +} diff --git a/server/mailer/src/ipv4.rs b/server/mailer/src/ipv4.rs new file mode 100644 index 0000000..34649be --- /dev/null +++ b/server/mailer/src/ipv4.rs @@ -0,0 +1,53 @@ +use crate::error::{MailerError, Result}; +use futures_util::future::join_all; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::time::Duration; + +/// Get a local IPv4 address to bind to. +/// GMail does not support sending to IPv6, hence we usually want to bind to an IPv4 interface. +/// +/// # Errors +/// - If listing all addresses failed. +/// - If no suitable address could be found. +pub async fn get_local_v4() -> Result { + let potential_addrs = nix::ifaddrs::getifaddrs() + .map_err(|e| MailerError::GetAddr(e))? + // Map to interface address + .filter_map(|iface| iface.address) + // Map to IPv4 address + .filter_map(|addr| addr.as_sockaddr_in().map(|addr4| addr4.ip())) + // Filter out loopback and link local addresses + .filter(|addr| !addr.is_loopback() && !addr.is_link_local()) + .collect::>(); + + // As we cannot determine if the address can reach the internet just by the address alone, try connecting over TCP + let connectable_addrs = join_all(potential_addrs.into_iter().map(|addr| async move { + // Open the socket and bind it to the address under test + let sock = tokio::net::TcpSocket::new_v4()?; + sock.bind(SocketAddr::V4(SocketAddrV4::new(addr, 0)))?; + + // Try connecting to the internet + match tokio::time::timeout( + Duration::from_secs(3), + sock.connect(SocketAddr::V4(SocketAddrV4::new( + // Address of example.com, run by IANA so very stable + Ipv4Addr::from([93, 184, 215, 14]), + 80, + ))), + ) + .await + { + Ok(stream_r) => stream_r.map(|_| addr), + Err(e) => Err(std::io::Error::new(std::io::ErrorKind::TimedOut, e)), + } + })) + .await + .into_iter() + .flatten() + .collect::>(); + + match connectable_addrs.get(0) { + Some(addr) => Ok(*addr), + None => Err(MailerError::NoIpv4), + } +} diff --git a/server/mailer/src/lib.rs b/server/mailer/src/lib.rs new file mode 100644 index 0000000..22b5abe --- /dev/null +++ b/server/mailer/src/lib.rs @@ -0,0 +1,65 @@ +//! Email sending library with Handlebars templates +//! +//! # Example +//! +//! ```no_run +//! # use mailer::email::Mailable; +//! # async fn main () { +//! // Get IPV4 address +//! let addr = mailer::ipv4::get_local_v4().await.unwrap(); +//! // Establish a connection +//! let mut connection = mailer::conn::get_connection( +//! addr, +//! "smtp-relay.gmail.com", +//! "array21.dev" +//! ).await.unwrap(); +//! +//! // Send a password forgotten email +//! mailer::email::PasswordForgottenEmail::send( +//! &mut connection, +//! "receiver@array21.dev", +//! "sender@array21.dev", +//! &mailer::email::PasswordForgottenData { +//! name: "Reciever name".to_string(), +//! temporary_password: "foobarbaz".to_string(), +//! }, +//! mailer::email::Locale::En, +//! // You can specify custom Handlebars partials to be used in the templates! +//! vec![ +//! mailer::email::HbsTemplate { +//! name: "banner".to_string(), +//! content: r#""#.to_string(), +//! } +//! ] +//! ).await.unwrap(); +//! # } +//! ``` + +pub mod conn; +pub mod email; +pub mod error; +pub mod ipv4; + +#[cfg(test)] +pub(crate) mod test { + use crate::email::HbsTemplate; + use lettre::transport::smtp::client::AsyncSmtpConnection; + use std::net::Ipv4Addr; + + async fn ipv4() -> Ipv4Addr { + crate::ipv4::get_local_v4().await.unwrap() + } + + pub async fn connection() -> AsyncSmtpConnection { + crate::conn::get_connection(ipv4().await, "smtp-relay.gmail.com", "array21.dev") + .await + .unwrap() + } + + pub fn banner_partial() -> HbsTemplate { + HbsTemplate { + name: "banner".to_string(), + content: r#""#.to_string(), + } + } +} diff --git a/server/mailer/templates/password_forgotten.en.hbs b/server/mailer/templates/password_forgotten.en.hbs new file mode 100644 index 0000000..711380a --- /dev/null +++ b/server/mailer/templates/password_forgotten.en.hbs @@ -0,0 +1,13 @@ + +{{> header }} + +
+ {{> banner }} + +

Hi {{ name }},

+

+ Your temporary password: {{ temporary_password }} +

+
+ + \ No newline at end of file diff --git a/server/mailer/templates/password_forgotten.nl.hbs b/server/mailer/templates/password_forgotten.nl.hbs new file mode 100644 index 0000000..8ec7c9a --- /dev/null +++ b/server/mailer/templates/password_forgotten.nl.hbs @@ -0,0 +1,13 @@ + +{{> header }} + +
+ {{> banner }} + +

Hoi {{ name }},

+

+ Jouw tijdelijke wachtwoord: {{ temporary_password }} +

+
+ + \ No newline at end of file diff --git a/server/wilford/Cargo.toml b/server/wilford/Cargo.toml index 0948332..9f99290 100644 --- a/server/wilford/Cargo.toml +++ b/server/wilford/Cargo.toml @@ -28,4 +28,5 @@ pem = "3.0.4" tracing-error = "0.2.0" rsa = "0.9.6" bcrypt = "0.16.0" -rand = "0.8.5" \ No newline at end of file +rand = "0.8.5" +mailer = { path = "../mailer" } \ No newline at end of file diff --git a/server/wilford/src/config.rs b/server/wilford/src/config.rs index 80d5ba0..898f711 100644 --- a/server/wilford/src/config.rs +++ b/server/wilford/src/config.rs @@ -35,6 +35,23 @@ pub struct Config { /// The issuer of OpenID Connect ID tokens. /// E.g. `mrfriendly.nl`. pub oidc_issuer: String, + /// Email configuration. + /// If this is not set, no emails will be sent, + /// useful for debugging + pub email: Option, +} + +#[derive(Debug, Deserialize)] +pub struct EmailConfig { + /// The SMTP host. + /// For Gmail this is `smtp-relay.gmail.com`. + pub smtp: String, + /// The `From` email address. Use `name syntax. + /// E.g. `Wilford `. + pub from: String, + /// The path to a Handlebars (`.hbs`) file. This banner + /// will be included at the top of every email. + pub banner_file: PathBuf, } #[derive(Debug, Deserialize)] diff --git a/server/wilford/src/mail.rs b/server/wilford/src/mail.rs new file mode 100644 index 0000000..664861f --- /dev/null +++ b/server/wilford/src/mail.rs @@ -0,0 +1,100 @@ +use crate::config::EmailConfig; +use mailer::email::{HbsTemplate, Locale, Mailable}; +use std::path::Path; +use thiserror::Error; +use tokio::fs; +use tokio::io::AsyncReadExt; + +#[derive(Debug, Error)] +pub enum MailerError { + #[error(transparent)] + Email(#[from] mailer::error::MailerError), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +pub struct WilfordMailer<'a> { + config: &'a EmailConfig, +} + +impl<'a> WilfordMailer<'a> { + pub fn new(config: &'a EmailConfig) -> Self { + Self { config } + } + + /// Send an email. + /// + /// # Errors + /// + /// - If the email could not be sent. + /// - If opening one of the configured Handlebars templates fails. + pub async fn send_email( + &self, + to: S, + // We don't need the actual value, just the type, + // but passing it as a parameter makes the code cleaner + // at the call site. + _: M, + mailer_data: &M::Data, + locale: Locale, + ) -> Result<(), MailerError> + where + S: AsRef, + M: Mailable, + { + // Establish the SMTP connection + let ipv4 = mailer::ipv4::get_local_v4().await?; + let mut conn = + mailer::conn::get_connection(ipv4, &self.config.smtp, &self.get_ehlo_domain()).await?; + + // Send the email + M::send( + &mut conn, + to.as_ref(), + &self.config.from, + mailer_data, + locale, + self.load_partials().await?, + ) + .await?; + + Ok(()) + } + + /// Get the EHLO domain from the configured `From` value. + fn get_ehlo_domain(&self) -> String { + // Original format: `Name ` + + // Trim to `domain>` + let domain = self.config.from.split("@").collect::>()[1]; + // Remove trailing `>` + domain.replace(">", "") + } + + /// Load all configured partials. + /// + /// # Errors + /// + /// If an IO error occurs. + async fn load_partials(&self) -> Result, MailerError> { + Ok(vec![ + Self::load_partial(&self.config.banner_file, "banner").await?, + ]) + } + + /// Load a file as a partial + /// + /// # Errors + /// + /// If an IO error occurs. + async fn load_partial>(p: P, name: &str) -> Result { + let mut f = fs::File::open(p).await?; + let mut content = String::new(); + f.read_to_string(&mut content).await?; + + Ok(HbsTemplate { + name: name.to_string(), + content, + }) + } +} diff --git a/server/wilford/src/main.rs b/server/wilford/src/main.rs index 4fcbd9d..d760fb2 100644 --- a/server/wilford/src/main.rs +++ b/server/wilford/src/main.rs @@ -18,6 +18,7 @@ use tracing_subscriber::EnvFilter; mod authorization; mod config; mod espo; +mod mail; mod response_types; mod routes; diff --git a/server/wilford/src/routes/error.rs b/server/wilford/src/routes/error.rs index f592054..f1fcb10 100644 --- a/server/wilford/src/routes/error.rs +++ b/server/wilford/src/routes/error.rs @@ -59,6 +59,8 @@ pub enum WebErrorKind { InternalServerError, #[error("Failed to parse PKCS8 SPKI: {0}")] RsaPkcs8Spki(#[from] rsa::pkcs8::spki::Error), + #[error("Failed to send email")] + Email(#[from] crate::mail::MailerError), } impl ResponseError for WebError { @@ -74,6 +76,7 @@ impl ResponseError for WebError { WebErrorKind::Espo(_) => StatusCode::BAD_GATEWAY, WebErrorKind::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, WebErrorKind::RsaPkcs8Spki(_) => StatusCode::INTERNAL_SERVER_ERROR, + WebErrorKind::Email(_) => StatusCode::INTERNAL_SERVER_ERROR, } } } diff --git a/server/wilford/src/routes/v1/auth/login.rs b/server/wilford/src/routes/v1/auth/login.rs index 6d8ccbf..d7806e1 100644 --- a/server/wilford/src/routes/v1/auth/login.rs +++ b/server/wilford/src/routes/v1/auth/login.rs @@ -15,7 +15,7 @@ use std::collections::HashSet; use tap::TapFallible; use tracing::{instrument, warn, warn_span, Instrument}; -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] pub struct Request { authorization: String, username: String, @@ -29,7 +29,7 @@ pub struct Response { totp_required: bool, } -#[instrument(skip(database, config))] +#[instrument(skip_all)] pub async fn login( database: WDatabase, config: WConfig, diff --git a/server/wilford/src/routes/v1/user/mod.rs b/server/wilford/src/routes/v1/user/mod.rs index 04f5641..cb5bbfd 100644 --- a/server/wilford/src/routes/v1/user/mod.rs +++ b/server/wilford/src/routes/v1/user/mod.rs @@ -5,6 +5,7 @@ use actix_web::web::ServiceConfig; mod change_password; mod info; mod list; +mod password_forgotten; mod permitted_scopes; mod register; mod registration_required; @@ -23,6 +24,10 @@ impl Routable for Router { "/registration-required", web::get().to(registration_required::registration_required), ) + .route( + "password-forgotten", + web::post().to(password_forgotten::password_forgotten), + ) .route( "/change-password", web::post().to(change_password::change_password), diff --git a/server/wilford/src/routes/v1/user/password_forgotten.rs b/server/wilford/src/routes/v1/user/password_forgotten.rs new file mode 100644 index 0000000..f70fea5 --- /dev/null +++ b/server/wilford/src/routes/v1/user/password_forgotten.rs @@ -0,0 +1,62 @@ +use crate::authorization::combined::CombinedAuthorizationProvider; +use crate::authorization::AuthorizationProvider; +use crate::mail::WilfordMailer; +use crate::response_types::Empty; +use crate::routes::error::{WebErrorKind, WebResult}; +use crate::routes::{auth_error_to_web_error, WConfig, WDatabase}; +use actix_web::web; +use database::user::User; +use mailer::email::{Locale, PasswordForgottenData, PasswordForgottenEmail}; +use rand::Rng; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Request { + email: String, +} + +pub async fn password_forgotten( + config: WConfig, + database: WDatabase, + payload: web::Json, +) -> WebResult { + // Fetch the user + let user = match User::get_by_email(&database, &payload.email).await? { + Some(user) => user, + None => return Ok(Empty), + }; + + let provider = CombinedAuthorizationProvider::new(&config, &database); + if !provider.supports_password_change() { + return Err(WebErrorKind::Unsupported.into()); + } + + // Update the password in the database + let tmp_password = tmp_password(); + auth_error_to_web_error(provider.set_password(&user.user_id, &tmp_password).await)?; + + // Email the user with their temporary password + if let Some(email_cfg) = &config.email { + WilfordMailer::new(email_cfg) + .send_email( + &user.email, + PasswordForgottenEmail, + &PasswordForgottenData { + name: user.name, + temporary_password: tmp_password, + }, + Locale::Nl, + ) + .await?; + } + + Ok(Empty) +} + +fn tmp_password() -> String { + rand::thread_rng() + .sample_iter(rand::distributions::Alphanumeric) + .take(16) + .map(char::from) + .collect() +} diff --git a/ui/src/components/banners/InfoBanner.vue b/ui/src/components/banners/InfoBanner.vue new file mode 100644 index 0000000..1661ac2 --- /dev/null +++ b/ui/src/components/banners/InfoBanner.vue @@ -0,0 +1,21 @@ + + + \ No newline at end of file diff --git a/ui/src/router/index.ts b/ui/src/router/index.ts index f7954b7..02e86ef 100644 --- a/ui/src/router/index.ts +++ b/ui/src/router/index.ts @@ -45,6 +45,11 @@ const routes = [ name: 'Login OK', component: () => import('@/views/LoginOk.vue') }, + { + path: '/password-forgotten', + name: "PasswordForgotten", + component: () => import('@/views/PasswordForgotten.vue') + } ] } ], diff --git a/ui/src/scripts/user.ts b/ui/src/scripts/user.ts index 9631a8c..e86268b 100644 --- a/ui/src/scripts/user.ts +++ b/ui/src/scripts/user.ts @@ -141,4 +141,16 @@ export class User { })) .map(() => {}); } + + static async resetPassword(email: string): Promise> { + return (await fetch1(`${server}/api/v1/user/password-forgotten`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: email, + }) + })).map(() => {}) + } } \ No newline at end of file diff --git a/ui/src/views/Login.vue b/ui/src/views/Login.vue index afdb7ea..0c7926e 100644 --- a/ui/src/views/Login.vue +++ b/ui/src/views/Login.vue @@ -39,6 +39,12 @@ variant="tonal"> Register + + Password forgotten + + + + + + Password forgotten + + + + + + + + Login + + + + Submit + + + + + + + \ No newline at end of file