diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 555328c..2d98362 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -48,8 +48,29 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - name: cargo test - run: cargo test --locked + - name: cargo test --lib + run: cargo test --lib --locked + integration-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: cargo test --test '*' + run: cargo test --test '*' --locked + os-test: + runs-on: ${{ matrix.os }} + name: os-test / ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: cargo test --lib + run: cargo test --lib --locked + - name: cargo test --test '*' + run: cargo test --test '*' --locked doc-test: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1493e8c..c08b7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ +### Unreleased + +- changed + - Refactor integration tests + - Update to rust 1.77 + ### v0.4.0 (2022-12-24) - added diff --git a/Cargo.lock b/Cargo.lock index 78c4bc8..6c38f5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "ahash" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a824f2aa7e75a0c98c5a504fceb80649e9c35265d44525b5f94de4771a395cd" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ "getrandom", "once_cell", @@ -30,9 +30,9 @@ dependencies = [ [[package]] name = "ahash" -version = "0.8.6" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", "getrandom", @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] @@ -80,17 +80,23 @@ dependencies = [ "winapi", ] +[[package]] +name = "anstyle" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" + [[package]] name = "anyhow" -version = "1.0.75" +version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" [[package]] name = "argminmax" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202108b46429b765ef483f8a24d5c46f48c14acfdacc086dd4ab6dddf6bcdbd2" +checksum = "52424b59d69d69d5056d508b260553afd91c57e21849579cd1f50ee8b8b88eaa" dependencies = [ "num-traits", ] @@ -123,7 +129,7 @@ version = "0.17.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59c468daea140b747d781a1da9f7db5f0a8e6636d4af20cc539e43d05b0604fa" dependencies = [ - "ahash 0.8.6", + "ahash 0.8.11", "arrow-format", "bytemuck", "chrono", @@ -146,15 +152,30 @@ dependencies = [ "zstd", ] +[[package]] +name = "assert_cmd" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed72493ac66d5804837f480ab3766c72bdfab91a65e565fc54fa9e42db0073a8" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" -version = "0.1.74" +version = "0.1.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" +checksum = "a507401cad91ec6a857ed5513a2073c82a9b9048762b885bb98655b306964681" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] @@ -179,15 +200,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" [[package]] name = "backtrace" -version = "0.3.69" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", "cc", @@ -206,9 +227,15 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" -version = "0.21.5" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" [[package]] name = "bitflags" @@ -218,9 +245,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" [[package]] name = "bitvec" @@ -254,9 +281,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d4d6dafc1a3bb54687538972158f07b2c948bc57d5890df22c0739098b3028" +checksum = "0901fc8eb0aca4c83be0106d6f2db17d86a08dfc2c25f0e84464bf381158add6" dependencies = [ "borsh-derive", "cfg_aliases", @@ -264,29 +291,40 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4918709cc4dd777ad2b6303ed03cb37f3ca0ccede8c1b0d28ac6db8f4710e0" +checksum = "51670c3aa053938b0ee3bd67c3817e471e626151131b934038e83c5bf8de48f5" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", "syn_derive", ] +[[package]] +name = "bstr" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" -version = "3.14.0" +version = "3.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" [[package]] name = "bytecheck" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6372023ac861f6e6dc89c8344a8f398fb42aaba2b5dbc649ca0c0e9dbcb627" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" dependencies = [ "bytecheck_derive", "ptr_meta", @@ -295,9 +333,9 @@ dependencies = [ [[package]] name = "bytecheck_derive" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7ec4c6f261935ad534c0c22dbef2201b45918860eb1c574b972bd213a76af61" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" dependencies = [ "proc-macro2", "quote", @@ -306,22 +344,22 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" +checksum = "5d6d68c57235a3a081186990eca2867354726650f42f7516ca50c28d6281fd15" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965ab7eb5f8f97d2a083c799f3a1b994fc397b2fe2da5d1da1626ce15a39f2b1" +checksum = "4da9a32f3fed317401fa3c862968128267c3106685286e15d5aaa3d7389c2f60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] @@ -332,15 +370,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" [[package]] name = "cc" -version = "1.0.83" +version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" dependencies = [ "jobserver", "libc", @@ -370,9 +408,9 @@ checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" [[package]] name = "chrono" -version = "0.4.31" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" dependencies = [ "android-tzdata", "iana-time-zone", @@ -380,7 +418,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.48.5", + "windows-targets 0.52.4", ] [[package]] @@ -462,53 +500,46 @@ checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "cpufeatures" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.3.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-deque" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fca89a0e215bab21874660c67903c5f143333cab1da83d041c7ded6053774751" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" dependencies = [ - "cfg-if", "crossbeam-epoch", "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" -version = "0.9.16" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2fe95351b870527a5d09bf563ed3c97c0cffb87cf1c78a591bf48bb218d9aa" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ - "autocfg", - "cfg-if", "crossbeam-utils", - "memoffset", ] [[package]] name = "crossbeam-utils" -version = "0.8.17" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d96137f14f244c37f989d9fff8f95e6c18b918e71f36638f8c49112e4c78f" -dependencies = [ - "cfg-if", -] +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" [[package]] name = "crossterm" @@ -516,7 +547,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.5.0", "crossterm_winapi", "libc", "parking_lot", @@ -582,6 +613,12 @@ dependencies = [ "sct 0.6.1", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -623,25 +660,31 @@ dependencies = [ "winapi", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dyn-clone" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" [[package]] name = "either" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" [[package]] name = "email-encoding" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75" +checksum = "a87260449b06739ee78d6281c68d2a0ff3e3af64a78df63d3a1aeb3c06997c8a" dependencies = [ - "base64 0.21.5", + "base64 0.22.0", "memchr", ] @@ -653,14 +696,14 @@ checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" [[package]] name = "enum_dispatch" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f33313078bb8d4d05a2733a94ac4c2d8a0df9a2b84424ebf4f33bfc224a890e" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] @@ -708,9 +751,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" [[package]] name = "fehler" @@ -738,6 +781,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -782,9 +834,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" dependencies = [ "futures-channel", "futures-core", @@ -797,9 +849,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", "futures-sink", @@ -807,15 +859,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-executor" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" dependencies = [ "futures-core", "futures-task", @@ -824,38 +876,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-macro" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] name = "futures-sink" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-channel", "futures-core", @@ -881,9 +933,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" dependencies = [ "cfg-if", "js-sys", @@ -906,9 +958,9 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "h2" -version = "0.3.22" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178" +checksum = "4fbd2820c5e49886948654ab546d0688ff24530286bdcf8fca3cefb16d4618eb" dependencies = [ "bytes", "fnv", @@ -916,7 +968,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap 2.1.0", + "indexmap 2.2.6", "slab", "tokio", "tokio-util", @@ -935,7 +987,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash 0.7.7", + "ahash 0.7.8", ] [[package]] @@ -944,7 +996,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" dependencies = [ - "ahash 0.8.6", + "ahash 0.8.11", "allocator-api2", "rayon", ] @@ -966,9 +1018,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.3.3" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "hex" @@ -1006,9 +1058,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", "fnv", @@ -1055,7 +1107,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.5", + "socket2 0.5.6", "tokio", "tower-service", "tracing", @@ -1081,9 +1133,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.58" +version = "0.1.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1134,9 +1186,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.1.0" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", "hashbrown 0.14.3", @@ -1171,24 +1223,24 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "jobserver" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +checksum = "ab46a6e9526ddef3ae7f787c06f0f2600639ba80ea3eade3d8e670a2230f51d6" dependencies = [ "libc", ] [[package]] name = "js-sys" -version = "0.3.66" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cee9c64da59eae3b50095c18d3e74f8b73c0b86d2792824ff01bbce68ba229ca" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" dependencies = [ "wasm-bindgen", ] @@ -1205,7 +1257,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d" dependencies = [ - "base64 0.21.5", + "base64 0.21.7", "email-encoding", "email_address", "fastrand 1.9.0", @@ -1220,7 +1272,7 @@ dependencies = [ "rustls-pemfile", "socket2 0.4.10", "tokio", - "uuid 1.6.1", + "uuid 1.8.0", "webpki-roots", ] @@ -1299,9 +1351,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.151" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "libm" @@ -1315,7 +1367,7 @@ version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.5.0", "libc", "redox_syscall", ] @@ -1328,9 +1380,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" [[package]] name = "lock_api" @@ -1344,9 +1396,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.20" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" [[package]] name = "lz4" @@ -1391,9 +1443,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" [[package]] name = "memmap2" @@ -1404,15 +1456,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memoffset" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -1427,18 +1470,18 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" dependencies = [ "adler", ] [[package]] name = "mio" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "wasi", @@ -1447,9 +1490,9 @@ dependencies = [ [[package]] name = "multiversion" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2c7b9d7fe61760ce5ea19532ead98541f6b4c495d87247aff9826445cf6872a" +checksum = "c4851161a11d3ad0bf9402d90ffc3967bf231768bfd7aeb61755ad06dbf1a142" dependencies = [ "multiversion-macros", "target-features", @@ -1457,9 +1500,9 @@ dependencies = [ [[package]] name = "multiversion-macros" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26a83d8500ed06d68877e9de1dde76c1dbb83885dcdbda4ef44ccbc3fbda2ac8" +checksum = "79a74ddee9e0c27d2578323c13905793e91622148f138ba29738f9dddb835e90" dependencies = [ "proc-macro2", "quote", @@ -1495,6 +1538,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "now" version = "0.1.3" @@ -1515,9 +1564,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", "libm", @@ -1529,15 +1578,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.3.3", + "hermit-abi 0.3.9", "libc", ] [[package]] name = "object" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -1550,17 +1599,17 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "opaque-debug" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.61" +version = "0.10.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b8419dc8cc6d866deb801274bba2e6f8f6108c1bb7fcc10ee5ab864931dbb45" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.5.0", "cfg-if", "foreign-types", "libc", @@ -1577,7 +1626,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] @@ -1588,9 +1637,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.97" +version = "0.9.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3eaad34cdd97d81de97964fc7f29e2d104f483840d906ef56daa1912338460b" +checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" dependencies = [ "cc", "libc", @@ -1659,6 +1708,7 @@ name = "pigeon-rs" version = "0.4.0" dependencies = [ "anyhow", + "assert_cmd", "base64 0.13.1", "bytes", "chrono", @@ -1669,11 +1719,13 @@ dependencies = [ "lettre", "polars", "postgres", + "predicates", "rusoto_core", "rusoto_credential", "rusoto_ses", "serde", "serde_yaml", + "tempfile", "tokio", "url", "uuid 0.8.2", @@ -1682,9 +1734,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" @@ -1694,9 +1746,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.27" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] name = "planus" @@ -1744,14 +1796,14 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b24f92fc5b167f668ff85ab9607dfa72e2c09664cacef59297ee8601dee60126" dependencies = [ - "ahash 0.8.6", + "ahash 0.8.11", "arrow2", - "bitflags 2.4.1", + "bitflags 2.5.0", "chrono", "comfy-table", "either", "hashbrown 0.14.3", - "indexmap 2.1.0", + "indexmap 2.2.6", "num-traits", "once_cell", "polars-arrow", @@ -1785,7 +1837,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92cab0df9f2a35702fa5aec99edfaabf9ae8e9cdd0acf69e143ad2d132f34f9c" dependencies = [ - "ahash 0.8.6", + "ahash 0.8.11", "arrow2", "async-trait", "bytes", @@ -1816,8 +1868,8 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c33762ec2a55e01c9f8776b34db86257c70a0a3b3929bd4eb91a52aacf61456" dependencies = [ - "ahash 0.8.6", - "bitflags 2.4.1", + "ahash 0.8.11", + "bitflags 2.5.0", "glob", "once_cell", "polars-arrow", @@ -1842,7 +1894,7 @@ dependencies = [ "argminmax", "arrow2", "either", - "indexmap 2.1.0", + "indexmap 2.2.6", "memchr", "polars-arrow", "polars-core", @@ -1878,7 +1930,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb67b014f0295e8e9dbb84404a91d666d477b3bc248a2ed51bc442833b16da35" dependencies = [ - "ahash 0.8.6", + "ahash 0.8.11", "arrow2", "once_cell", "polars-arrow", @@ -1945,7 +1997,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c326708a370d71dc6e11a8f4bbc10a8479e1c314dc048ba73543b815cd0bf339" dependencies = [ - "ahash 0.8.6", + "ahash 0.8.11", "hashbrown 0.14.3", "num-traits", "once_cell", @@ -2002,7 +2054,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49b6c5ef183cd3ab4ba005f1ca64c21e8bd97ce4699cfea9e8d9a2c4958ca520" dependencies = [ - "base64 0.21.5", + "base64 0.21.7", "byteorder", "bytes", "fallible-iterator", @@ -2035,13 +2087,42 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "predicates" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-crate" -version = "2.0.1" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97dc5fea232fc28d2f597b37c4876b348a40e33f3b02cc975c8d006d78d94b1a" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" dependencies = [ - "toml_datetime", "toml_edit", ] @@ -2070,9 +2151,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" dependencies = [ "unicode-ident", ] @@ -2099,9 +2180,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -2181,9 +2262,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c27db03db7734835b3f53954b534c91069375ce6ccaa2e065441e07d9b6cdb1" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" dependencies = [ "either", "rayon-core", @@ -2191,9 +2272,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce3fb6ad83f861aac485e76e1985cd109d9a3713802152be56c3b1f0e0658ed" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -2221,25 +2302,25 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.2" +version = "1.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.2", + "regex-syntax 0.8.3", ] [[package]] name = "regex-automata" -version = "0.4.3" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.2", + "regex-syntax 0.8.3", ] [[package]] @@ -2250,15 +2331,15 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" [[package]] name = "rend" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2571463863a6bd50c32f94402933f03457a3fbaf697a707c5be741e459f08fd" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" dependencies = [ "bytecheck", ] @@ -2280,23 +2361,24 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.7" +version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", + "cfg-if", "getrandom", "libc", "spin 0.9.8", "untrusted 0.9.0", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "rkyv" -version = "0.7.43" +version = "0.7.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527a97cdfef66f65998b5f3b637c26f5a5ec09cc52a3f9932313ac645f4190f5" +checksum = "5cba464629b3394fc4dbc6f940ff8f5b4ff5c7aef40f29166fd4ad12acbc99c0" dependencies = [ "bitvec", "bytecheck", @@ -2307,14 +2389,14 @@ dependencies = [ "rkyv_derive", "seahash", "tinyvec", - "uuid 1.6.1", + "uuid 1.8.0", ] [[package]] name = "rkyv_derive" -version = "0.7.43" +version = "0.7.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c462a1328c8e67e4d6dbad1eb0355dd43e8ab432c6e227a43657f16ade5033" +checksum = "a7dddfff8de25e6f62b9d64e6e432bf1c6736c57d20323e15ee10435fbda7c65" dependencies = [ "proc-macro2", "quote", @@ -2406,15 +2488,15 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.33.1" +version = "1.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06676aec5ccb8fc1da723cc8c0f9a46549f21ebb8753d3915c6c41db1e7f1dc4" +checksum = "1790d1c4c0ca81211399e0e0af16333276f375209e71a37b67698a373db5b47a" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "postgres", + "postgres-types", "rand", "rkyv", "serde", @@ -2423,9 +2505,9 @@ dependencies = [ [[package]] name = "rust_decimal_macros" -version = "1.33.1" +version = "1.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e43721f4ef7060ebc2c3ede757733209564ca8207f47674181bcd425dd76945" +checksum = "e418701588729bef95e7a655f2b483ad64bb97c46e8e79fde83efd92aaab6d82" dependencies = [ "quote", "rust_decimal", @@ -2448,11 +2530,11 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.28" +version = "0.38.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" +checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" dependencies = [ - "bitflags 2.4.1", + "bitflags 2.5.0", "errno", "libc", "linux-raw-sys", @@ -2479,7 +2561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" dependencies = [ "log", - "ring 0.17.7", + "ring 0.17.8", "rustls-webpki 0.101.7", "sct 0.7.1", ] @@ -2502,7 +2584,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64 0.21.5", + "base64 0.21.7", ] [[package]] @@ -2521,7 +2603,7 @@ version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "ring 0.17.7", + "ring 0.17.8", "untrusted 0.9.0", ] @@ -2533,17 +2615,17 @@ checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" [[package]] name = "ryu" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" [[package]] name = "schannel" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -2577,7 +2659,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "ring 0.17.7", + "ring 0.17.8", "untrusted 0.9.0", ] @@ -2589,9 +2671,9 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "security-framework" -version = "2.9.2" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" dependencies = [ "bitflags 1.3.2", "core-foundation", @@ -2602,9 +2684,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.9.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +checksum = "41f3cc463c0ef97e11c3461a9d3787412d30e8e7eb907c79180c4a57bf7c04ef" dependencies = [ "core-foundation-sys", "libc", @@ -2612,35 +2694,35 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" +checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" [[package]] name = "serde" -version = "1.0.193" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.193" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] name = "serde_json" -version = "1.0.108" +version = "1.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" dependencies = [ "itoa", "ryu", @@ -2697,9 +2779,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" @@ -2733,9 +2815,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.2" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smartstring" @@ -2760,12 +2842,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -2849,7 +2931,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] @@ -2871,9 +2953,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.41" +version = "2.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" +checksum = "11a6ae1e52eb25aab8f3fb9fca13be982a373b8f1157ca14b897a825ba4a2d35" dependencies = [ "proc-macro2", "quote", @@ -2889,7 +2971,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] @@ -2914,23 +2996,28 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "target-features" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfb5fa503293557c5158bd215fdc225695e567a77e453f5d4452a50a193969bd" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" [[package]] name = "tempfile" -version = "3.8.1" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", - "fastrand 2.0.1", - "redox_syscall", + "fastrand 2.0.2", "rustix", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "textwrap" version = "0.11.0" @@ -2942,22 +3029,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.51" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f11c217e1416d6f036b870f14e0413d480dbf28edbee1f877abaf0206af43bb7" +checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.51" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" +checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] @@ -2977,9 +3064,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.35.0" +version = "1.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d45b238a16291a4e1584e61820b8ae57d696cc5015c459c229ccc6990cc1c" +checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" dependencies = [ "backtrace", "bytes", @@ -2988,7 +3075,7 @@ dependencies = [ "num_cpus", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.5", + "socket2 0.5.6", "tokio-macros", "windows-sys 0.48.0", ] @@ -3001,7 +3088,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] @@ -3046,7 +3133,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand", - "socket2 0.5.5", + "socket2 0.5.6", "tokio", "tokio-util", "whoami", @@ -3079,17 +3166,17 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" [[package]] name = "toml_edit" -version = "0.20.2" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" dependencies = [ - "indexmap 2.1.0", + "indexmap 2.2.6", "toml_datetime", "winnow", ] @@ -3133,9 +3220,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "unicode-bidi" -version = "0.3.14" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-ident" @@ -3145,9 +3232,9 @@ checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-normalization" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" dependencies = [ "tinyvec", ] @@ -3192,9 +3279,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.6.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560" +checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" dependencies = [ "getrandom", ] @@ -3217,6 +3304,15 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" @@ -3232,11 +3328,17 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" -version = "0.2.89" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ed0d4f68a3015cc185aff4db9506a015f4b96f95303897bfa23f846db54064e" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -3244,24 +3346,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.89" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b56f625e64f3a1084ded111c4d5f477df9f8c92df113852fa5a374dbda78826" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.89" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0162dbf37223cd2afce98f3d0785506dcb8d266223983e4b5b525859e6e182b2" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3269,28 +3371,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.89" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.89" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" [[package]] name = "web-sys" -version = "0.3.66" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c24a44ec86bb68fbecd1b3efed7e85ea5621b39b35ef2766b66cd984f8010f" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" dependencies = [ "js-sys", "wasm-bindgen", @@ -3317,11 +3419,12 @@ dependencies = [ [[package]] name = "whoami" -version = "1.4.1" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" +checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" dependencies = [ - "wasm-bindgen", + "redox_syscall", + "wasite", "web-sys", ] @@ -3349,11 +3452,11 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.51.1" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.48.5", + "windows-targets 0.52.4", ] [[package]] @@ -3371,7 +3474,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.4", ] [[package]] @@ -3391,17 +3494,17 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" dependencies = [ - "windows_aarch64_gnullvm 0.52.0", - "windows_aarch64_msvc 0.52.0", - "windows_i686_gnu 0.52.0", - "windows_i686_msvc 0.52.0", - "windows_x86_64_gnu 0.52.0", - "windows_x86_64_gnullvm 0.52.0", - "windows_x86_64_msvc 0.52.0", + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", ] [[package]] @@ -3412,9 +3515,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" [[package]] name = "windows_aarch64_msvc" @@ -3424,9 +3527,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" [[package]] name = "windows_i686_gnu" @@ -3436,9 +3539,9 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" [[package]] name = "windows_i686_msvc" @@ -3448,9 +3551,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" [[package]] name = "windows_x86_64_gnu" @@ -3460,9 +3563,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" [[package]] name = "windows_x86_64_gnullvm" @@ -3472,9 +3575,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" [[package]] name = "windows_x86_64_msvc" @@ -3484,15 +3587,15 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" [[package]] name = "winnow" -version = "0.5.30" +version = "0.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b5c3db89721d50d0e2a673f5043fc4722f76dcc352d7b1ab8b8288bed4ed2c5" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" dependencies = [ "memchr", ] @@ -3514,9 +3617,9 @@ checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a" [[package]] name = "xxhash-rust" -version = "0.8.7" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9828b178da53440fa9c766a3d2f73f7cf5d0ac1fe3980c1e5018d899fd19e07b" +checksum = "927da81e25be1e1a2901d59b81b37dd2efd1fc9c9345a55007f09bf5a2d3ee03" [[package]] name = "yaml-rust" @@ -3529,22 +3632,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.7.31" +version = "0.7.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c4061bedbb353041c12f413700357bec76df2c7e2ca8e4df8bac24c6bf68e3d" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.31" +version = "0.7.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.57", ] [[package]] @@ -3574,9 +3677,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.9+zstd.1.5.5" +version = "2.0.10+zstd.1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" +checksum = "c253a4914af5bafc8fa8c86ee400827e83cf6ec01195ec1f1ed8441bf00d65aa" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 2761490..ad051f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,14 @@ categories = ["command-line-utilities", "email"] readme = "README.md" license = "Apache-2.0" +[[bin]] +name = "pigeon" +path = "src/main.rs" + +[[test]] +name = "integration_tests" +path = "tests/cmd/lib.rs" + [dependencies] anyhow = "1.0.40" rusoto_ses = { version = "0.47.0", default-features = false, features = ["rustls"] } @@ -34,6 +42,7 @@ infer = "0.5.0" bytes = "1.1.0" base64 = "0.13.0" -[[bin]] -name = "pigeon" -path = "src/main.rs" +[dev-dependencies] +assert_cmd = "2.0.14" +predicates = "3.1.0" +tempfile = "3.10.1" diff --git a/README.md b/README.md index 627c266..ac26f11 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ # Pigeon +[![Latest Version](https://img.shields.io/crates/v/bogrep.svg)](https://crates.io/crates/bogrep) +[![Build +Status](https://github.com/quambene/bogrep/actions/workflows/rust-ci.yml/badge.svg)](https://github.com/quambene/bogrep/actions/workflows/rust-ci.yml) +[![codecov](https://codecov.io/gh/quambene/pigeon-rs/graph/badge.svg)](https://app.codecov.io/gh/quambene/pigeon-rs) + Pigeon is a command line tool for automating your email workflow in a cheap and efficient way. Utilize your most efficient dev tools you are already familiar with. For example, query the subscribers of your newsletter, create a plaintext and html email from a template file, and send it to all of them: @@ -54,6 +59,7 @@ elie@cartan.com ... ok - [Third-party APIs](#third-party-apis) - [Data sources](#data-sources) - [Comparison with Mailchimp, Sendgrid, and ConvertKit](#comparison-with-mailchimp-sendgrid-and-convertkit) +- [Testing](#testing) ## Install Pigeon @@ -436,3 +442,34 @@ provider | daily limit Pigeon+AWS | 50,000 Mailchimp | equals monthly limit Sendgrid | equals monthly limit + +## Testing + +Integration tests require a locally running database, and an AWS SES account. +Specify the following environment variables: + +- SMTP + - `SMTP_SERVER` + - `SMTP_USERNAME` + - `SMTP_PASSWORD` +- AWS SES + - `AWS_ACCESS_KEY_ID` + - `AWS_SECRET_ACCESS_KEY` + - `AWS_REGION` +- Postgres + - `DB_HOST` + - `DB_PORT` + - `DB_USER` + - `DB_PASSWORD` + - `DB_NAME` + +``` bash +# Run unit tests and integration tests +cargo test + +# Run unit tests +cargo test --lib + +# Run integration tests +cargo test --test '*' +``` diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 4d2d28e..5887fd4 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.74" +channel = "1.77" profile = "default" \ No newline at end of file diff --git a/src/arg.rs b/src/arg.rs index 1585a17..94445a4 100644 --- a/src/arg.rs +++ b/src/arg.rs @@ -1,3 +1,6 @@ +use anyhow::anyhow; +use clap::ArgMatches; + // args for command pub const VERBOSE: &str = "verbose"; @@ -32,7 +35,14 @@ pub mod val { // default value for RECEIVER_COLUMN pub const EMAIL: &str = "email"; - // possible values for arg CONNECTION and subcommand CONNECT + // possible values for argument CONNECTION and subcommand CONNECT pub const SMTP: &str = "smtp"; pub const AWS: &str = "aws"; } + +pub fn value<'a>(name: &str, matches: &'a ArgMatches<'a>) -> Result<&'a str, anyhow::Error> { + match matches.value_of(name) { + Some(query) => Ok(query), + None => Err(anyhow!("Missing value for argument '{}'", name)), + } +} diff --git a/src/cmd/connect.rs b/src/cmd/connect.rs index 3f1a0e9..9938bf5 100644 --- a/src/cmd/connect.rs +++ b/src/cmd/connect.rs @@ -5,21 +5,7 @@ use crate::{ email_transmission::SmtpClient, }; use anyhow::{anyhow, Result}; -use clap::{Arg, ArgMatches}; - -pub fn connect_args() -> [Arg<'static, 'static>; 2] { - [ - Arg::with_name(cmd::CONNECT) - .takes_value(true) - .possible_values(&[val::SMTP, val::AWS]) - .default_value(val::SMTP) - .help("Check connection to SMTP server."), - Arg::with_name(arg::VERBOSE) - .long(arg::VERBOSE) - .takes_value(false) - .help("Shows what is going on for subcommand"), - ] -} +use clap::ArgMatches; pub fn connect(matches: &ArgMatches) -> Result<(), anyhow::Error> { if matches.is_present(arg::VERBOSE) { diff --git a/src/cmd/init.rs b/src/cmd/init.rs index 5d29713..8c9bd91 100644 --- a/src/cmd/init.rs +++ b/src/cmd/init.rs @@ -1,17 +1,53 @@ -use crate::{arg, email_builder::MessageTemplate}; -use clap::{Arg, ArgMatches}; - -pub fn init_args() -> [Arg<'static, 'static>; 1] { - [Arg::with_name(arg::VERBOSE) - .long(arg::VERBOSE) - .takes_value(false) - .help("Shows what is going on for subcommand")] -} +use crate::{arg, email_builder::Message}; +use anyhow::Context; +use clap::ArgMatches; +use std::{env, io}; pub fn init(matches: &ArgMatches) -> Result<(), anyhow::Error> { if matches.is_present(arg::VERBOSE) { println!("matches: {:#?}", matches); } - MessageTemplate::create(matches) + let current_dir = env::current_dir().context("Can't get current directory")?; + let file_name = Message::template_name(); + let template_path = current_dir.join(file_name); + + match template_path.exists() { + true => { + let mut input = String::new(); + println!("Message template already exists. Should this template be overwritten? Yes (y) or no (n)"); + loop { + io::stdin() + .read_line(&mut input) + .context("Can't read input")?; + match input.trim() { + "y" | "yes" | "Yes" => { + println!( + "Overwriting message template in current directory '{}' ...", + template_path.display() + ); + Message::write_template(&template_path)?; + break; + } + "n" | "no" | "No" => { + println!("Aborted ..."); + break; + } + _ => { + println!("Choose yes (y) or no (n). Try again."); + continue; + } + } + } + } + false => { + println!( + "Creating message template in current directory: {} ...", + template_path.display() + ); + Message::write_template(&template_path)?; + } + } + + Ok(()) } diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 4c390bf..031f839 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -6,13 +6,13 @@ mod send; mod send_bulk; mod simple_query; -pub use connect::{connect, connect_args}; -pub use init::{init, init_args}; -pub use query::{query, query_args}; -pub use read::{read, read_args}; -pub use send::{send, send_args}; -pub use send_bulk::{send_bulk, send_bulk_args}; -pub use simple_query::{simple_query, simple_query_args}; +pub use connect::connect; +pub use init::init; +pub use query::query; +pub use read::read; +pub use send::send; +pub use send_bulk::send_bulk; +pub use simple_query::simple_query; // Binary name pub const BIN: &str = "pigeon"; diff --git a/src/cmd/query.rs b/src/cmd/query.rs index 12d90dc..5d2ebaf 100644 --- a/src/cmd/query.rs +++ b/src/cmd/query.rs @@ -1,58 +1,11 @@ use crate::{ - arg::{self}, - cmd, - data_sources::{query_postgres, write_csv, write_image}, + arg, cmd, + sources::{self, ConnVars, DbConnection}, }; use anyhow::{anyhow, Result}; -use clap::{Arg, ArgMatches}; - -pub fn query_args() -> [Arg<'static, 'static>; 9] { - [ - Arg::with_name(cmd::QUERY) - .index(1) - .required(true) - .takes_value(true) - .help("Takes a sql query"), - Arg::with_name(arg::SSH_TUNNEL) - .long(arg::SSH_TUNNEL) - .value_name("port") - .takes_value(true) - .help("Connect to db through ssh tunnel"), - Arg::with_name(arg::SAVE) - .long(arg::SAVE) - .takes_value(false) - .help("Save query result"), - Arg::with_name(arg::SAVE_DIR) - .long(arg::SAVE_DIR) - .takes_value(true) - .default_value("./saved_queries") - .help("Specifies the output directory for saved query"), - Arg::with_name(arg::FILE_TYPE) - .long(arg::FILE_TYPE) - .takes_value(true) - .default_value("csv") - .possible_values(&["csv", "jpg", "png"]) - .help("Specifies the file type for saved query"), - Arg::with_name(arg::IMAGE_COLUMN) - .long(arg::IMAGE_COLUMN) - .required_ifs(&[(arg::FILE_TYPE, "jpg"), (arg::FILE_TYPE, "png")]) - .takes_value(true) - .help("Specifies the column in which to look for images"), - Arg::with_name(arg::IMAGE_NAME) - .long(arg::IMAGE_NAME) - .required_ifs(&[(arg::FILE_TYPE, "jpg"), (arg::FILE_TYPE, "png")]) - .takes_value(true) - .help("Specifies the column used for the image name"), - Arg::with_name(arg::DISPLAY) - .long(arg::DISPLAY) - .takes_value(false) - .help("Print query result to terminal"), - Arg::with_name(arg::VERBOSE) - .long(arg::VERBOSE) - .takes_value(false) - .help("Shows what is going on for subcommand"), - ] -} +use chrono::Utc; +use clap::ArgMatches; +use std::path::Path; pub fn query(matches: &ArgMatches) -> Result<(), anyhow::Error> { if matches.is_present(arg::VERBOSE) { @@ -62,19 +15,27 @@ pub fn query(matches: &ArgMatches) -> Result<(), anyhow::Error> { if matches.is_present(cmd::QUERY) { match matches.value_of(cmd::QUERY) { Some(query) => { - let df_query_result = query_postgres(matches, query)?; + let now = Utc::now(); + let conn_vars = ConnVars::from_env()?; + let ssh_tunnel = matches.value_of(arg::SSH_TUNNEL); + + let connection = DbConnection::new(&conn_vars, ssh_tunnel)?; + let mut df_query = sources::query_postgres(&connection, query)?; if matches.is_present(arg::DISPLAY) { - println!("Display query result: {}", df_query_result); + println!("Display query result: {}", df_query); } if matches.is_present(arg::SAVE) { // If argument 'FILE_TYPE' is not present the default value 'csv' will be used match matches.value_of(arg::FILE_TYPE) { Some(file_type) => match file_type { - "csv" => write_csv(matches, df_query_result)?, - x if x == "jpg" => write_image(matches, df_query_result, x)?, - x if x == "png" => write_image(matches, df_query_result, x)?, + "csv" => { + let save_dir = Path::new(arg::value(arg::SAVE_DIR, matches)?); + sources::write_csv(&mut df_query, save_dir, now)?; + } + x if x == "jpg" => sources::write_image(matches, df_query, x)?, + x if x == "png" => sources::write_image(matches, df_query, x)?, _ => { return Err(anyhow!( "Value '{}' not supported for argument '{}'", @@ -97,75 +58,3 @@ pub fn query(matches: &ArgMatches) -> Result<(), anyhow::Error> { Err(anyhow!("Missing argument '{}'", cmd::QUERY)) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{app, cmd}; - use std::env; - - #[test] - #[ignore] - fn test_display_query() { - let test_query = env::var("TEST_QUERY").expect("Missing environment variable 'TEST_QUERY'"); - let args = vec![cmd::BIN, cmd::QUERY, test_query.as_str(), "--display"]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::QUERY).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = query(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_save_query() { - let test_query = env::var("TEST_QUERY").expect("Missing environment variable 'TEST_QUERY'"); - let args = vec![ - cmd::BIN, - cmd::QUERY, - test_query.as_str(), - "--display", - "--save", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::QUERY).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = query(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_save_dir() { - let test_query = env::var("TEST_QUERY").expect("Missing environment variable 'TEST_QUERY'"); - let args = vec![ - cmd::BIN, - cmd::QUERY, - test_query.as_str(), - "--display", - "--save", - "--save-dir", - "./my-saved-queries", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::QUERY).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = query(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } -} diff --git a/src/cmd/read.rs b/src/cmd/read.rs index 7da9f11..47725d7 100644 --- a/src/cmd/read.rs +++ b/src/cmd/read.rs @@ -1,22 +1,8 @@ -use crate::{arg, cmd, data_sources::read_csv}; +use crate::{arg, cmd, sources}; use anyhow::{anyhow, Result}; -use clap::{Arg, ArgMatches}; +use clap::ArgMatches; use std::path::PathBuf; -pub fn read_args() -> [Arg<'static, 'static>; 3] { - [ - Arg::with_name(cmd::READ).required(true).takes_value(true), - Arg::with_name(arg::VERBOSE) - .long(arg::VERBOSE) - .takes_value(false) - .help("Shows what is going on for subcommand"), - Arg::with_name(arg::DISPLAY) - .long(arg::DISPLAY) - .takes_value(false) - .help("Display csv file in terminal"), - ] -} - pub fn read(matches: &ArgMatches) -> Result<(), anyhow::Error> { if matches.is_present(arg::VERBOSE) { println!("matches: {:#?}", matches); @@ -26,7 +12,7 @@ pub fn read(matches: &ArgMatches) -> Result<(), anyhow::Error> { match matches.value_of(cmd::READ) { Some(csv_file) => { let path = PathBuf::from(csv_file); - let csv = read_csv(&path)?; + let csv = sources::read_csv(&path)?; if matches.is_present(arg::DISPLAY) { println!("Display csv file: {}", csv); @@ -40,24 +26,3 @@ pub fn read(matches: &ArgMatches) -> Result<(), anyhow::Error> { Err(anyhow!("Missing argument '{}'", cmd::READ)) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{app, cmd}; - - #[test] - fn test_read() { - let args = vec![cmd::BIN, cmd::READ, "./test_data/receiver.csv"]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::READ).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = read(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } -} diff --git a/src/cmd/send.rs b/src/cmd/send.rs index bc22c36..ac4cdef 100644 --- a/src/cmd/send.rs +++ b/src/cmd/send.rs @@ -1,769 +1,102 @@ use crate::{ - arg::{self, val}, - email_builder::{Confirmed, Email}, + arg, + email_builder::{Confirmed, Email, Message, MimeFormat, Receiver, Sender}, email_formatter::EmlFormatter, email_transmission::Client, - helper::format_green, + utils::format_green, }; -use anyhow::Result; -use clap::{Arg, ArgMatches}; - -pub fn send_args() -> [Arg<'static, 'static>; 15] { - [ - Arg::with_name(arg::SENDER) - .index(1) - .required(true) - .takes_value(true) - .requires_all(&[arg::RECEIVER]) - .help("Email address of the sender"), - Arg::with_name(arg::RECEIVER) - .index(2) - .required(true) - .takes_value(true) - .requires_all(&[arg::SENDER]) - .help("Email address of the receiver"), - Arg::with_name(arg::SUBJECT) - .long(arg::SUBJECT) - .takes_value(true) - .required_unless_one(&[arg::MESSAGE_FILE]) - .help("Subject of the email"), - Arg::with_name(arg::CONTENT) - .long(arg::CONTENT) - .takes_value(true) - .requires(arg::SUBJECT) - .required_unless_one(&[arg::MESSAGE_FILE, arg::TEXT_FILE, arg::HTML_FILE]) - .conflicts_with_all(&[arg::MESSAGE_FILE, arg::TEXT_FILE, arg::HTML_FILE]) - .help("Content of the email"), - Arg::with_name(arg::MESSAGE_FILE) - .long(arg::MESSAGE_FILE) - .takes_value(true) - .required_unless_one(&[arg::SUBJECT, arg::CONTENT, arg::TEXT_FILE, arg::HTML_FILE]) - .conflicts_with_all(&[arg::CONTENT, arg::TEXT_FILE, arg::HTML_FILE]) - .help("Path of the message file"), - Arg::with_name(arg::TEXT_FILE) - .long(arg::TEXT_FILE) - .takes_value(true) - .requires(arg::SUBJECT) - .conflicts_with_all(&[arg::CONTENT, arg::MESSAGE_FILE]) - .help("Path of text file"), - Arg::with_name(arg::HTML_FILE) - .long(arg::HTML_FILE) - .takes_value(true) - .requires(arg::SUBJECT) - .conflicts_with_all(&[arg::CONTENT, arg::MESSAGE_FILE]) - .help("Path of html file"), - Arg::with_name(arg::ATTACHMENT) - .long(arg::ATTACHMENT) - .takes_value(true) - .help("Path of attachment"), - Arg::with_name(arg::ARCHIVE) - .long(arg::ARCHIVE) - .takes_value(false) - .help("Archive sent emails"), - Arg::with_name(arg::ARCHIVE_DIR) - .long(arg::ARCHIVE_DIR) - .takes_value(true) - .default_value("./sent_emails") - .help("Path of sent emails"), - Arg::with_name(arg::DISPLAY) - .long(arg::DISPLAY) - .takes_value(false) - .help("Display email in terminal"), - Arg::with_name(arg::DRY_RUN) - .long(arg::DRY_RUN) - .takes_value(false) - .help("Prepare email but do not send email"), - Arg::with_name(arg::ASSUME_YES) - .long(arg::ASSUME_YES) - .takes_value(false) - .help("Send email without confirmation"), - Arg::with_name(arg::CONNECTION) - .long(arg::CONNECTION) - .takes_value(true) - .possible_values(&[val::SMTP, val::AWS]) - .default_value(val::SMTP) - .help("Send emails via SMTP or AWS API"), - Arg::with_name(arg::VERBOSE) - .long(arg::VERBOSE) - .takes_value(false) - .help("Shows what is going on for subcommand"), - ] -} +use anyhow::Context; +use chrono::Utc; +use clap::ArgMatches; +use std::{io, path::Path, time::SystemTime}; pub fn send(matches: &ArgMatches) -> Result<(), anyhow::Error> { if matches.is_present(arg::VERBOSE) { println!("matches: {:#?}", matches); } - let email = Email::build(matches)?; + let now = SystemTime::now(); + let dry_run = matches.is_present(arg::DRY_RUN); + let is_archived = matches.is_present(arg::ARCHIVE); + let archive_dir = Path::new(arg::value(arg::ARCHIVE_DIR, matches)?); + let sender = Sender(arg::value(arg::SENDER, matches)?); + let receiver = Receiver(arg::value(arg::RECEIVER, matches)?); + let message = Message::from_args(matches)?; + let attachment = matches.value_of(arg::ATTACHMENT).map(Path::new); + let mime_format = MimeFormat::new(sender, receiver, &message, attachment, now)?; + let email = Email::new(sender, receiver, &message, &mime_format)?; if matches.is_present(arg::DISPLAY) { println!("Display email: {:#?}", email); } - if matches.is_present(arg::DRY_RUN) { + if dry_run { println!("Dry run: {}", format_green("activated")); } - let client = Client::init(matches)?; - let eml_formatter = EmlFormatter::new(matches)?; + let now = Utc::now(); + let client = Client::from_args(matches)?; + let eml_formatter = EmlFormatter::new(archive_dir)?; - println!("Sending email to 1 recipient ..."); + println!("Sending email to 1 receiver ..."); if matches.is_present(arg::ASSUME_YES) { - let sent_email = client.send(matches, &email)?; + let sent_email = client.send(&email)?; sent_email.display_status(); - eml_formatter.archive(matches, &email)?; + + if is_archived { + eml_formatter.archive(&email, now, dry_run)?; + } } else { - let confirmation = email.confirm(matches)?; + let confirmation = confirm_email(&email)?; match confirmation { Confirmed::Yes => { - let sent_email = client.send(matches, &email)?; + let sent_email = client.send(&email)?; sent_email.display_status(); - eml_formatter.archive(matches, &email)?; + + if is_archived { + eml_formatter.archive(&email, now, dry_run)?; + } } Confirmed::No => (), } } if matches.is_present(arg::DRY_RUN) { - println!("Email sent (dry run)."); + println!("Email sent (dry run)"); } else { - println!("Email sent."); + println!("Email sent"); } Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - use crate::{app, cmd}; - use std::env; - - #[test] - #[ignore] - fn test_send_subject_content_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--subject", - "Test Subject", - "--content", - "This is a test message (plaintext).", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_subject_content_smtp() { - let sender = env::var("TEST_SENDER").expect("Missing environment variable 'TEST_SENDER'"); - let receiver = - env::var("TEST_RECEIVER").expect("Missing environment variable 'TEST_RECEIVER'"); - - let args = vec![ - cmd::BIN, - cmd::SEND, - &sender, - &receiver, - "--subject", - "Test Subject", - "--content", - "This is a test message (plaintext).", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_message_file_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_message_file_smtp() { - let sender = env::var("TEST_SENDER").expect("Missing environment variable 'TEST_SENDER'"); - let receiver = - env::var("TEST_RECEIVER").expect("Missing environment variable 'TEST_RECEIVER'"); - - let args = vec![ - cmd::BIN, - cmd::SEND, - &sender, - &receiver, - "--message-file", - "./test_data/message.yaml", - "--display", - "--assume-yes", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_message_file_aws_api() { - let sender = env::var("TEST_SENDER").expect("Missing environment variable 'TEST_SENDER'"); - let receiver = - env::var("TEST_RECEIVER").expect("Missing environment variable 'TEST_RECEIVER'"); - - let args = vec![ - cmd::BIN, - cmd::SEND, - &sender, - &receiver, - "--connection", - val::AWS, - "--message-file", - "./test_data/message.yaml", - "--display", - "--assume-yes", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_message_file_empty_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--message-file", - "./test_data/empty_message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_message_file_none_html_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--message-file", - "./test_data/none_html_message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_message_file_content_none_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--message-file", - "./test_data/content_none_message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_err()) - } - - #[test] - #[ignore] - fn test_archive_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_archive_dir_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - "--archive-dir", - "./my-sent-emails", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_attachment_pdf_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - "--attachment", - "./test_data/test.pdf", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_attachment_png_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - "--attachment", - "./test_data/test.png", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_attachment_odt_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - "--attachment", - "./test_data/test.odt", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_attachment_pdf_smtp() { - let sender = env::var("TEST_SENDER").expect("Missing environment variable 'TEST_SENDER'"); - let receiver = - env::var("TEST_RECEIVER").expect("Missing environment variable 'TEST_RECEIVER'"); - - let args = vec![ - cmd::BIN, - cmd::SEND, - &sender, - &receiver, - "--message-file", - "./test_data/message.yaml", - "--display", - "--assume-yes", - "--archive", - "--attachment", - "./test_data/test.pdf", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_attachment_pdf_aws_api() { - let sender = env::var("TEST_SENDER").expect("Missing environment variable 'TEST_SENDER'"); - let receiver = - env::var("TEST_RECEIVER").expect("Missing environment variable 'TEST_RECEIVER'"); - - let args = vec![ - cmd::BIN, - cmd::SEND, - &sender, - &receiver, - "--connection", - val::AWS, - "--message-file", - "./test_data/message.yaml", - "--display", - "--assume-yes", - "--archive", - "--attachment", - "./test_data/test.pdf", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_aws_api_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--subject", - "Test Subject", - "--content", - "This is a test message (plaintext).", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - "--connection", - val::AWS, - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_aws_api() { - let sender = env::var("TEST_SENDER").expect("Missing environment variable 'TEST_SENDER'"); - let receiver = - env::var("TEST_RECEIVER").expect("Missing environment variable 'TEST_RECEIVER'"); - - let args = vec![ - cmd::BIN, - cmd::SEND, - &sender, - &receiver, - "--subject", - "Test Subject", - "--content", - "This is a test message (plaintext).", - "--display", - "--assume-yes", - "--archive", - "--connection", - val::AWS, - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_text_file_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--subject", - "Test Subject", - "--text-file", - "./test_data/message.txt", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_html_file_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--subject", - "Test Subject", - "--html-file", - "./test_data/message.html", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_text_file_html_file_smtp_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND, - "albert@einstein.com", - "marie@curie.com", - "--subject", - "Test Subject", - "--text-file", - "./test_data/message.txt", - "--html-file", - "./test_data/message.html", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_text_file_html_file_smtp() { - let sender = env::var("TEST_SENDER").expect("Missing environment variable 'TEST_SENDER'"); - let receiver = - env::var("TEST_RECEIVER").expect("Missing environment variable 'TEST_RECEIVER'"); - - let args = vec![ - cmd::BIN, - cmd::SEND, - &sender, - &receiver, - "--subject", - "Test Subject", - "--text-file", - "./test_data/message.txt", - "--html-file", - "./test_data/message.html", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } +pub fn confirm_email(email: &Email) -> Result { + let mut input = String::new(); + + println!( + "Preparing to send an email to 1 recipient: {}", + email.receiver.0 + ); + + println!("Should an email be sent to 1 recipient? Yes (y) or no (n)"); + let confirmation = loop { + io::stdin() + .read_line(&mut input) + .context("Can't read input")?; + match input.trim() { + "y" | "yes" | "Yes" => { + break Confirmed::Yes; + } + "n" | "no" | "No" => { + println!("Aborted ..."); + break Confirmed::No; + } + _ => { + println!("Choose yes (y) or no (n). Try again."); + continue; + } + } + }; + Ok(confirmation) } diff --git a/src/cmd/send_bulk.rs b/src/cmd/send_bulk.rs index b893f07..8146951 100644 --- a/src/cmd/send_bulk.rs +++ b/src/cmd/send_bulk.rs @@ -1,470 +1,143 @@ use crate::{ - arg::{self, val}, - email_builder::{BulkEmail, Confirmed, Message, Receiver, Sender}, - helper::format_green, + arg, + email_builder::{BulkEmail, BulkReceiver, Confirmed, Email, Message, Sender}, + email_formatter::EmlFormatter, + email_transmission::Client, + utils::format_green, }; -use anyhow::Result; -use clap::{Arg, ArgMatches}; - -pub fn send_bulk_args() -> [Arg<'static, 'static>; 19] { - [ - Arg::with_name(arg::SENDER) - .index(1) - .required(true) - .takes_value(true) - .help("Email address of the sender"), - Arg::with_name(arg::RECEIVER_FILE) - .long(arg::RECEIVER_FILE) - .required_unless(arg::RECEIVER_QUERY) - .takes_value(true) - .help("Email addresses of multiple receivers fetched from provided csv file"), - Arg::with_name(arg::RECEIVER_QUERY) - .long(arg::RECEIVER_QUERY) - .required_unless(arg::RECEIVER_FILE) - .takes_value(true) - .help("Email addresses of multiple receivers fetched from provided query"), - Arg::with_name(arg::SUBJECT) - .long(arg::SUBJECT) - .takes_value(true) - .required_unless_one(&[arg::MESSAGE_FILE]) - .help("Subject of the email"), - Arg::with_name(arg::CONTENT) - .long(arg::CONTENT) - .takes_value(true) - .requires(arg::SUBJECT) - .required_unless_one(&[arg::MESSAGE_FILE, arg::TEXT_FILE, arg::HTML_FILE]) - .conflicts_with_all(&[arg::MESSAGE_FILE, arg::TEXT_FILE, arg::HTML_FILE]) - .help("Content of the email"), - Arg::with_name(arg::MESSAGE_FILE) - .long(arg::MESSAGE_FILE) - .takes_value(true) - .required_unless_one(&[arg::SUBJECT, arg::CONTENT, arg::TEXT_FILE, arg::HTML_FILE]) - .conflicts_with_all(&[arg::CONTENT, arg::TEXT_FILE, arg::HTML_FILE]) - .help("Path of the message file"), - Arg::with_name(arg::TEXT_FILE) - .long(arg::TEXT_FILE) - .takes_value(true) - .requires(arg::SUBJECT) - .conflicts_with_all(&[arg::CONTENT, arg::MESSAGE_FILE]) - .help("Path of text file"), - Arg::with_name(arg::HTML_FILE) - .long(arg::HTML_FILE) - .takes_value(true) - .requires(arg::SUBJECT) - .conflicts_with_all(&[arg::CONTENT, arg::MESSAGE_FILE]) - .help("Path of html file"), - Arg::with_name(arg::ATTACHMENT) - .long(arg::ATTACHMENT) - .takes_value(true) - .help("Path of attachment"), - Arg::with_name(arg::ARCHIVE) - .long(arg::ARCHIVE) - .takes_value(false) - .help("Archive sent emails"), - Arg::with_name(arg::ARCHIVE_DIR) - .long(arg::ARCHIVE_DIR) - .takes_value(true) - .default_value("./sent_emails") - .help("Path of sent emails"), - Arg::with_name(arg::RECEIVER_COLUMN) - .long(arg::RECEIVER_COLUMN) - .takes_value(true) - .default_value(val::EMAIL) - .help("Specifies the column in which to look for email addresses"), - Arg::with_name(arg::PERSONALIZE) - .long(arg::PERSONALIZE) - .takes_value(true) - .multiple(true) - .max_values(100) - .help("Personalizes email for variables defined in the message template"), - Arg::with_name(arg::DISPLAY) - .long(arg::DISPLAY) - .takes_value(false) - .help("Print emails to terminal"), - Arg::with_name(arg::DRY_RUN) - .long(arg::DRY_RUN) - .takes_value(false) - .help("Prepare emails but do not send emails"), - Arg::with_name(arg::ASSUME_YES) - .long(arg::ASSUME_YES) - .takes_value(false) - .help("Send emails without confirmation"), - Arg::with_name(arg::SSH_TUNNEL) - .long(arg::SSH_TUNNEL) - .value_name("port") - .takes_value(true) - .help("Query db through ssh tunnel"), - Arg::with_name(arg::CONNECTION) - .long(arg::CONNECTION) - .takes_value(true) - .possible_values(&[val::SMTP, val::AWS]) - .default_value(val::SMTP) - .help("Send emails via SMTP or AWS API"), - Arg::with_name(arg::VERBOSE) - .long(arg::VERBOSE) - .takes_value(false) - .help("Shows what is going on for subcommand"), - ] -} +use anyhow::{anyhow, Context, Result}; +use chrono::Utc; +use clap::ArgMatches; +use std::{io, path::Path}; pub fn send_bulk(matches: &ArgMatches) -> Result<(), anyhow::Error> { if matches.is_present(arg::VERBOSE) { println!("matches: {:#?}", matches); } - let sender = Sender::init(matches)?; - let df_receiver = Receiver::dataframe(matches)?; - let default_message = Message::build(matches)?; - let bulk_email = BulkEmail::build(matches, sender, &df_receiver, &default_message)?; + let dry_run = matches.is_present(arg::DRY_RUN); + let is_archived = matches.is_present(arg::ARCHIVE); + let archive_dir = Path::new(arg::value(arg::ARCHIVE_DIR, matches)?); + let sender = Sender(arg::value(arg::SENDER, matches)?); + let receivers = BulkReceiver::from_args(matches)?; + let message = Message::from_args(matches)?; + let attachment = matches.value_of(arg::ATTACHMENT).map(Path::new); + + let bulk_email = if matches.is_present(arg::PERSONALIZE) { + if let Some(personalized_columns) = matches.values_of(arg::PERSONALIZE) { + let personalized_columns = personalized_columns.collect::>(); + BulkEmail::new( + sender, + &receivers, + &message, + attachment, + &personalized_columns, + )? + } else { + return Err(anyhow!("Missing value for argument '{}'", arg::PERSONALIZE)); + } + } else { + BulkEmail::new(sender, &receivers, &message, attachment, &[])? + }; + let client = Client::from_args(matches)?; + let eml_formatter = EmlFormatter::new(archive_dir)?; if matches.is_present(arg::DISPLAY) { println!("Display emails: {:#?}", bulk_email); } - if matches.is_present(arg::DRY_RUN) { + if dry_run { println!("Dry run: {}", format_green("activated")); } if matches.is_present(arg::ASSUME_YES) { - bulk_email.process(matches)?; + process_emails( + &client, + &eml_formatter, + &bulk_email.emails, + dry_run, + is_archived, + )?; } else { - let confirmation = bulk_email.confirm()?; + let confirmation = confirm_emails(&bulk_email.emails)?; match confirmation { Confirmed::Yes => { - bulk_email.process(matches)?; + process_emails( + &client, + &eml_formatter, + &bulk_email.emails, + dry_run, + is_archived, + )?; } Confirmed::No => (), } } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{app, cmd}; - - #[test] - #[ignore] - fn test_send_bulk_subject_content_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--subject", - "Test Subject", - "--content", - "This is a test message (plaintext).", - "--dry-run", - "--display", - "--assume-yes", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_bulk_text_file_html_file_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--subject", - "Test Subject", - "--text-file", - "./test_data/message.txt", - "--html-file", - "./test_data/message.html", - "--dry-run", - "--display", - "--assume-yes", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_bulk_message_file_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_bulk_receiver_column_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/contacts.csv", - "--message-file", - "./test_data/message.yaml", - "--receiver-column", - "contact", - "--dry-run", - "--display", - "--assume-yes", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_send_bulk_personalize_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--message-file", - "./test_data/message_personalized.yaml", - "--personalize", - "first_name", - "last_name", - "--dry-run", - "--display", - "--assume-yes", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_archive_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_archive_dir_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - "--archive-dir", - "./my-sent-emails", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_attachment_pdf_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - "--attachment", - "./test_data/test.pdf", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) - } - - #[test] - #[ignore] - fn test_attachment_png_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - "--attachment", - "./test_data/test.png", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); - - assert!(res.is_ok()) + if dry_run { + println!("All emails sent (dry run)"); + } else { + println!("All emails sent"); } - #[test] - #[ignore] - fn test_attachment_odt_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--archive", - "--attachment", - "./test_data/test.odt", - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); + Ok(()) +} - assert!(res.is_ok()) +pub fn process_emails<'a>( + client: &Client<'a>, + eml_formatter: &EmlFormatter, + emails: &'a [Email], + dry_run: bool, + is_archived: bool, +) -> Result<(), anyhow::Error> { + println!("Sending email to {} receivers ...", emails.len()); + + for email in emails { + let sent_email = client.send(email)?; + sent_email.display_status(); + + if is_archived { + let now = Utc::now(); + eml_formatter.archive(email, now, dry_run)?; + } } - #[test] - #[ignore] - fn test_send_bulk_aws_dry() { - let args = vec![ - cmd::BIN, - cmd::SEND_BULK, - "albert@einstein.com", - "--receiver-file", - "./test_data/receiver.csv", - "--message-file", - "./test_data/message.yaml", - "--dry-run", - "--display", - "--assume-yes", - "--connection", - val::AWS, - ]; - - let app = app(); - let matches = app.get_matches_from(args); - let subcommand_matches = matches.subcommand_matches(cmd::SEND_BULK).unwrap(); - println!("subcommand matches: {:#?}", subcommand_matches); - - let res = send_bulk(subcommand_matches); - println!("res: {:#?}", res); + Ok(()) +} - assert!(res.is_ok()) - } +pub fn confirm_emails(emails: &[Email]) -> Result { + let mut input = String::new(); + let email_count = emails.len(); + let receivers = emails + .iter() + .map(|email| email.receiver.as_ref()) + .collect::>(); + println!( + "Preparing to send an email to {} recipients: {:#?}", + email_count, receivers + ); + + println!( + "Should an email be sent to {} recipients? Yes (y) or no (n)", + email_count + ); + let confirmation = loop { + io::stdin() + .read_line(&mut input) + .context("Can't read input")?; + match input.trim() { + "y" | "yes" | "Yes" => { + break Confirmed::Yes; + } + "n" | "no" | "No" => { + println!("Aborted ..."); + break Confirmed::No; + } + _ => { + println!("Choose yes (y) or no (n). Try again."); + continue; + } + } + }; + Ok(confirmation) } diff --git a/src/cmd/simple_query.rs b/src/cmd/simple_query.rs index 1648c69..c9426cc 100644 --- a/src/cmd/simple_query.rs +++ b/src/cmd/simple_query.rs @@ -1,22 +1,8 @@ -use crate::{arg, cmd, data_sources::ConnVars}; +use crate::{arg, cmd, sources::ConnVars}; use anyhow::{anyhow, Result}; -use clap::{Arg, ArgMatches}; +use clap::ArgMatches; use postgres::{Client, NoTls, SimpleQueryMessage}; -pub fn simple_query_args() -> [Arg<'static, 'static>; 2] { - [ - Arg::with_name(cmd::SIMPLE_QUERY) - .index(1) - .required(true) - .takes_value(true) - .help("Takes a sql query"), - Arg::with_name(arg::VERBOSE) - .long(arg::VERBOSE) - .takes_value(false) - .help("Shows what is going on for subcommand"), - ] -} - pub fn simple_query(matches: &ArgMatches) -> Result<(), anyhow::Error> { if matches.is_present(arg::VERBOSE) { println!("matches: {:#?}", matches); diff --git a/src/data_loader/mod.rs b/src/data_loader/mod.rs deleted file mode 100644 index 6725ce8..0000000 --- a/src/data_loader/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod tabular_data; - -pub use tabular_data::TabularData; diff --git a/src/data_loader/tabular_data.rs b/src/data_loader/tabular_data.rs deleted file mode 100644 index 557fbc7..0000000 --- a/src/data_loader/tabular_data.rs +++ /dev/null @@ -1,74 +0,0 @@ -use crate::{ - arg, - data_sources::{query_postgres, read_csv}, - email_builder::Receiver, -}; -use anyhow::{anyhow, Context}; -use clap::ArgMatches; -use polars::{ - chunked_array::ChunkedArray, - prelude::{DataFrame, TakeRandom, Utf8Type}, -}; -use std::path::PathBuf; - -pub struct TabularData; - -impl TabularData { - pub fn from_query(matches: &ArgMatches) -> Result { - let receiver_query = Receiver::query(matches)?; - let df_receiver = query_postgres(matches, receiver_query)?; - - if matches.is_present(arg::DISPLAY) { - println!("Display query result: {}", df_receiver); - } - - Ok(df_receiver) - } - - pub fn from_file(matches: &ArgMatches) -> Result { - let receiver_file = Receiver::file_name(matches)?; - let path = PathBuf::from(receiver_file); - let df_receiver = read_csv(&path)?; - - if matches.is_present(arg::DISPLAY) { - println!("Display csv file: {}", df_receiver); - } - - Ok(df_receiver) - } - - pub fn row<'a>( - index: usize, - column_name: &str, - df: &'a DataFrame, - ) -> Result<&'a str, anyhow::Error> { - let column_value = df - .column(column_name) - .context(format!("Missing column '{}'", column_name))? - .utf8() - .context("Can't convert series to chunked array")? - .get(index); - - match column_value { - Some(column_value) => Ok(column_value), - None => Err(anyhow!( - "Missing value for column '{}' in row {}", - column_name, - index - )), - } - } - - pub fn column<'a>( - column_name: &str, - df: &'a DataFrame, - ) -> Result<&'a ChunkedArray, anyhow::Error> { - let column = df - .column(column_name) - .context(format!("Missing column '{}'", column_name))? - .utf8() - .context("Can't convert series to chunked array")?; - - Ok(column) - } -} diff --git a/src/data_sources/csv.rs b/src/data_sources/csv.rs deleted file mode 100644 index 5e36497..0000000 --- a/src/data_sources/csv.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::arg; -use anyhow::{anyhow, Context}; -use clap::ArgMatches; -use polars::prelude::{CsvReader, CsvWriter, DataFrame, SerReader, SerWriter}; -use std::{ - fs, - path::{Path, PathBuf}, - time::SystemTime, -}; - -pub fn read_csv(csv_file: &Path) -> Result { - println!("Reading csv file '{}' ...", csv_file.display()); - let reader = CsvReader::from_path(csv_file)?.has_header(true); - let df = reader.finish()?; - Ok(df) -} - -pub fn write_csv(matches: &ArgMatches, mut df: DataFrame) -> Result<(), anyhow::Error> { - let now = SystemTime::now(); - let now_utc: chrono::DateTime = now.into(); - let current_time = now_utc.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - - let target_dir = match matches.value_of(arg::SAVE_DIR) { - Some(save_dir) => PathBuf::from(save_dir), - None => return Err(anyhow!("Missing value for argument '{}'", arg::SAVE_DIR)), - }; - let target_file = format!("query_{}.csv", ¤t_time); - - match target_dir.exists() { - true => (), - false => fs::create_dir(&target_dir).context(format!( - "Can't create directory: '{}'", - target_dir.display() - ))?, - } - - let target_path = target_dir.join(target_file); - println!("Save query result to file: {}", target_path.display()); - - let csv_file = &mut fs::File::create(target_path)?; - let timestamp_format = "%F_H:%M:%S"; - - CsvWriter::new(csv_file) - .with_datetime_format(Some(timestamp_format.to_string())) - .finish(&mut df)?; - - Ok(()) -} diff --git a/src/email_builder/bulk_email.rs b/src/email_builder/bulk_email.rs deleted file mode 100644 index cba37a9..0000000 --- a/src/email_builder/bulk_email.rs +++ /dev/null @@ -1,150 +0,0 @@ -use crate::{ - arg, - data_loader::TabularData, - email_builder::{Confirmed, Email, Message, MimeFormat, Receiver}, - email_formatter::EmlFormatter, - email_transmission::Client, -}; -use anyhow::{anyhow, Context, Result}; -use clap::{ArgMatches, Values}; -use polars::prelude::DataFrame; -use std::io; - -#[derive(Debug)] -pub struct BulkEmail<'a> { - pub emails: Vec>, -} - -impl<'a> BulkEmail<'a> { - pub fn build( - matches: &'a ArgMatches, - sender: &'a str, - df_receiver: &'a DataFrame, - default_message: &'a Message, - ) -> Result { - let bulk_email = if matches.is_present(arg::PERSONALIZE) { - match matches.values_of(arg::PERSONALIZE) { - Some(personalized_columns) => BulkEmail::personalize( - matches, - sender, - df_receiver, - default_message, - personalized_columns, - )?, - None => return Err(anyhow!("Missing value for argument '{}'", arg::PERSONALIZE)), - } - } else { - BulkEmail::new(matches, sender, df_receiver, default_message)? - }; - - Ok(bulk_email) - } - - pub fn new( - matches: &'a ArgMatches, - sender: &'a str, - df_receiver: &'a DataFrame, - message: &'a Message, - ) -> Result { - let mut emails: Vec = vec![]; - let receiver_column_name = Receiver::column_name(matches)?; - let receivers = TabularData::column(receiver_column_name, df_receiver)?; - - for receiver in receivers { - match receiver { - Some(receiver) => { - let mime_format = MimeFormat::new(matches, sender, receiver, message)?; - let email = Email::new(sender, receiver, message, &mime_format)?; - emails.push(email); - } - None => continue, - } - } - - Ok(BulkEmail { emails }) - } - - pub fn personalize( - matches: &ArgMatches, - sender: &'a str, - df_receiver: &'a DataFrame, - default_message: &Message, - personalized_columns: Values, - ) -> Result { - let mut emails: Vec = vec![]; - let columns: Vec<&str> = personalized_columns.collect(); - let receiver_column_name = Receiver::column_name(matches)?; - - for i in 0..df_receiver.height() { - let mut message = default_message.clone(); - message.personalize(i, df_receiver, &columns)?; - - let receiver = TabularData::row(i, receiver_column_name, df_receiver)?; - let mime_format = MimeFormat::new(matches, sender, receiver, &message)?; - let email = Email::new(sender, receiver, &message, &mime_format)?; - - emails.push(email); - } - - Ok(BulkEmail { emails }) - } - - pub fn process(&self, matches: &ArgMatches) -> Result<(), anyhow::Error> { - let client = Client::init(matches)?; - let eml_formatter = EmlFormatter::new(matches)?; - - println!("Sending email to {} receivers ...", self.emails.len()); - - for email in &self.emails { - let sent_email = client.send(matches, email)?; - sent_email.display_status(); - eml_formatter.archive(matches, email)?; - } - - if matches.is_present(arg::DRY_RUN) { - println!("All emails sent (dry run)."); - } else { - println!("All emails sent."); - } - - Ok(()) - } - - pub fn confirm(&self) -> Result { - let mut input = String::new(); - let email_count = self.emails.len(); - let receivers: Vec = self - .emails - .iter() - .map(|email| email.receiver.to_string()) - .collect(); - println!( - "Preparing to send an email to {} recipients: {:#?}", - email_count, receivers - ); - - println!( - "Should an email be sent to {} recipients? Yes (y) or no (n)", - email_count - ); - let confirmation = loop { - io::stdin() - .read_line(&mut input) - .context("Can't read input")?; - match input.trim() { - "y" | "yes" | "Yes" => { - break Confirmed::Yes; - } - "n" | "no" | "No" => { - println!("Aborted ..."); - break Confirmed::No; - } - _ => { - println!("Choose yes (y) or no (n). Try again."); - continue; - } - } - }; - Ok(confirmation) - } -} diff --git a/src/email_builder/email.rs b/src/email_builder/email.rs index 9da661f..a761c76 100644 --- a/src/email_builder/email.rs +++ b/src/email_builder/email.rs @@ -1,32 +1,20 @@ -use crate::{ - arg, - email_builder::{Confirmed, Message, MimeFormat, Receiver, Sender}, -}; -use anyhow::{anyhow, Context, Result}; -use clap::ArgMatches; -use std::io; +use super::{BulkReceiver, Receiver, Sender}; +use crate::email_builder::{Message, MimeFormat}; +use anyhow::Result; +use std::{path::Path, time::SystemTime}; #[derive(Debug)] pub struct Email<'a> { - pub sender: &'a str, - pub receiver: &'a str, + pub sender: Sender<'a>, + pub receiver: Receiver<'a>, pub message: Message, pub mime_format: MimeFormat, } impl<'a> Email<'a> { - pub fn build(matches: &'a ArgMatches) -> Result { - let sender = Sender::init(matches)?; - let receiver = Receiver::init(matches)?; - let message = Message::build(matches)?; - let mime_format = MimeFormat::new(matches, sender, receiver, &message)?; - let email = Email::new(sender, receiver, &message, &mime_format)?; - Ok(email) - } - pub fn new( - sender: &'a str, - receiver: &'a str, + sender: Sender<'a>, + receiver: Receiver<'a>, message: &Message, mime_format: &MimeFormat, ) -> Result { @@ -38,34 +26,135 @@ impl<'a> Email<'a> { }; Ok(email) } +} - pub fn confirm(&self, matches: &ArgMatches) -> Result { - let mut input = String::new(); +#[derive(Debug)] +pub struct BulkEmail<'a> { + pub emails: Vec>, +} - match matches.value_of(arg::RECEIVER) { - Some(receiver) => println!("Preparing to send an email to 1 recipient: {}", receiver), - None => return Err(anyhow!("Missing value for argument '{}'", arg::RECEIVER)), - } +impl<'a> BulkEmail<'a> { + pub fn new( + sender: Sender<'a>, + bulk_receiver: &'a BulkReceiver, + message: &'a Message, + attachment: Option<&Path>, + personalized_columns: &[&str], + ) -> Result { + let now = SystemTime::now(); + let mut emails: Vec = vec![]; - println!("Should an email be sent to 1 recipient? Yes (y) or no (n)"); - let confirmation = loop { - io::stdin() - .read_line(&mut input) - .context("Can't read input")?; - match input.trim() { - "y" | "yes" | "Yes" => { - break Confirmed::Yes; - } - "n" | "no" | "No" => { - println!("Aborted ..."); - break Confirmed::No; - } - _ => { - println!("Choose yes (y) or no (n). Try again."); - continue; + if personalized_columns.is_empty() { + let receivers = bulk_receiver.receiver_column()?; + for receiver in receivers.into_iter().flatten() { + let mime_format = + MimeFormat::new(sender, Receiver(receiver), message, attachment, now)?; + let email = Email::new(sender, Receiver(receiver), message, &mime_format)?; + emails.push(email); + } + } else { + for i in 0..bulk_receiver.height() { + let mut message = message.clone(); + + for &col_name in personalized_columns.iter() { + let col_value = bulk_receiver.row(i, col_name)?; + message.personalize(col_name, col_value); } + + let receiver = bulk_receiver.receiver_row(i)?; + let mime_format = + MimeFormat::new(sender, Receiver(receiver), &message, attachment, now)?; + let email = Email::new(sender, Receiver(receiver), &message, &mime_format)?; + + emails.push(email); } - }; - Ok(confirmation) + } + + Ok(BulkEmail { emails }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use polars::{frame::DataFrame, prelude::NamedFrom, series::Series}; + + #[test] + fn test_bulk_email() { + let sender = Sender("albert@einstein.com"); + let subject = "Test Subject"; + let text = "This is a test message (plaintext)."; + let html = "

This is a test message (html).

"; + let message = Message::new(subject, Some(text), Some(html)); + let column_name = "email"; + let receiver_column = Series::new(column_name, &["marie@curie.com", "emmy@noether.com"]); + let df_receiver = DataFrame::new(vec![receiver_column]).unwrap(); + let receivers = BulkReceiver::new(column_name.to_owned(), df_receiver); + + let res = BulkEmail::new(sender, &receivers, &message, None, &[]); + assert!(res.is_ok()); + + let emails = res.unwrap().emails; + assert_eq!(emails.len(), 2); + assert!(emails.iter().any(|email| email.sender == sender)); + + let receivers = emails + .iter() + .map(|email| email.receiver) + .collect::>(); + assert!(receivers.contains(&Receiver("marie@curie.com"))); + assert!(receivers.contains(&Receiver("emmy@noether.com"))); + } + + #[test] + fn test_bulk_email_personalized() { + let sender = Sender("albert@einstein.com"); + let subject = "Test Subject"; + let text = r#"Dear {first_name} {last_name}, +This is a test message (plaintext)."#; + let html = r#"Dear {first_name} {last_name}, +
+
+This is a test message (html)."#; + let message = Message::new(subject, Some(text), Some(html)); + let first_name_column = Series::new("first_name", &["Marie", "Emmy"]); + let last_name_column = Series::new("last_name", &["Curie", "Noether"]); + let email_column = Series::new("email", &["marie@curie.com", "emmy@noether.com"]); + let df_receiver = + DataFrame::new(vec![first_name_column, last_name_column, email_column]).unwrap(); + let receivers = BulkReceiver::new("email".to_owned(), df_receiver); + + let res = BulkEmail::new( + sender, + &receivers, + &message, + None, + &["first_name", "last_name"], + ); + assert!(res.is_ok()); + + let emails = res.unwrap().emails; + let receivers = emails + .iter() + .map(|email| email.receiver) + .collect::>(); + assert!(receivers.contains(&Receiver("marie@curie.com"))); + assert!(receivers.contains(&Receiver("emmy@noether.com"))); + + let text_messages = emails + .iter() + .map(|email| email.message.text.as_ref().unwrap().as_str()) + .collect::>(); + assert!(text_messages.contains(&"Dear Marie Curie,\nThis is a test message (plaintext).")); + assert!(text_messages.contains(&"Dear Emmy Noether,\nThis is a test message (plaintext).")); + + let html_messages = emails + .iter() + .map(|email| email.message.html.as_ref().unwrap().as_str()) + .collect::>(); + assert!(html_messages + .contains(&"Dear Marie Curie,\n
\n
\nThis is a test message (html).")); + assert!(html_messages + .contains(&"Dear Emmy Noether,\n
\n
\nThis is a test message (html).")); } } diff --git a/src/email_builder/message.rs b/src/email_builder/message.rs new file mode 100644 index 0000000..35a1e2d --- /dev/null +++ b/src/email_builder/message.rs @@ -0,0 +1,308 @@ +use crate::{arg, utils}; +use anyhow::{anyhow, Context, Result}; +use clap::ArgMatches; +use serde::Deserialize; +use std::{ + fs::{self, File}, + io::Write, + path::Path, +}; + +const TEMPLATE_FILE_NAME: &str = "message.yaml"; + +static MESSAGE_TEMPLATE: &str = r##"# Specify the subject, plaintext and html version of your email. +# Personalize message by wrapping variables in curly brackets, eg. {first_name}. + +# The subject of your email +subject: "" +# The plaintext version +text: "" +# The html version +html: "" +"##; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Message { + pub subject: String, + pub text: Option, + pub html: Option, +} + +impl Message { + pub fn new(subject: S, text: Option, html: Option) -> Self + where + S: Into, + { + Self { + subject: subject.into(), + text: text.map(|text| text.into()), + html: html.map(|text| text.into()), + } + } + + pub fn from_args(matches: &ArgMatches) -> Result { + if matches.is_present(arg::SUBJECT) && matches.is_present(arg::CONTENT) { + match ( + matches.value_of(arg::SUBJECT), + matches.value_of(arg::CONTENT), + ) { + (Some(subject), Some(content)) => { + let message = Message::new(subject, Some(content), None); + Ok(message) + } + (Some(_), None) => Err(anyhow!("Missing value for argument '{}'", arg::CONTENT)), + (None, Some(_)) => Err(anyhow!("Missing value for argument '{}'", arg::SUBJECT)), + (None, None) => Err(anyhow!( + "Missing values for '{}' and '{}'", + arg::SUBJECT, + arg::CONTENT + )), + } + } else if matches.is_present(arg::MESSAGE_FILE) { + let message_file = arg::value(arg::MESSAGE_FILE, matches)?; + let message_path = Path::new(message_file); + let message = Message::read_yaml(message_path)?; + + if matches.is_present(arg::DISPLAY) { + println!("Display message file: {:#?}", message); + } + + Ok(message) + } else if matches.is_present(arg::SUBJECT) + && (matches.is_present(arg::TEXT_FILE) || matches.is_present(arg::HTML_FILE)) + { + let subject = arg::value(arg::SUBJECT, matches)?; + let text_path = matches.value_of(arg::TEXT_FILE).map(Path::new); + let html_path = matches.value_of(arg::HTML_FILE).map(Path::new); + let text = if let Some(path) = text_path { + Some(utils::read_file(path)?) + } else { + None + }; + let html = if let Some(path) = html_path { + Some(utils::read_file(path)?) + } else { + None + }; + let message = Message::new(subject, text.as_deref(), html.as_deref()); + Ok(message) + } else { + Err(anyhow!( + "Missing arguments. Please provide {} and {} or {}", + arg::SUBJECT, + arg::CONTENT, + arg::MESSAGE_FILE, + )) + } + } + + pub fn personalize(&mut self, col_name: &str, col_value: &str) { + self.subject = self + .subject + .replace(&format!("{{{}}}", col_name), col_value); + self.text = self + .text + .as_ref() + .map(|text| text.replace(&format!("{{{}}}", col_name), col_value)); + self.html = self + .html + .as_ref() + .map(|html| html.replace(&format!("{{{}}}", col_name), col_value)); + } + + fn read_yaml(path: &Path) -> Result { + println!("Reading message file '{}' ...", path.display()); + let yaml = fs::read_to_string(path)?; + let message = serde_yaml::from_str(&yaml)?; + Ok(message) + } + + pub fn template_name() -> &'static str { + TEMPLATE_FILE_NAME + } + + pub fn write_template(path: &Path) -> Result<(), anyhow::Error> { + let mut message_file = File::create(path).context("Unable to create message template.")?; + message_file.write_all(MESSAGE_TEMPLATE.as_bytes())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app; + + #[test] + fn test_read_yaml() { + let yaml_path = Path::new("./test_data/message.yaml"); + let res = Message::read_yaml(yaml_path); + assert!(res.is_ok()); + + let message = res.unwrap(); + assert_eq!( + message, + Message { + subject: "Test subject".to_owned(), + text: Some("This is a test message (plaintext).".to_owned()), + html: Some("

This is a test message (html).

".to_owned()) + } + ) + } + + #[test] + fn test_read_yaml_empty() { + let yaml_path = Path::new("./test_data/message_empty.yaml"); + let res = Message::read_yaml(yaml_path); + assert!(res.is_ok()); + + let message = res.unwrap(); + assert_eq!( + message, + Message { + subject: "Test subject".to_owned(), + text: Some("".to_owned()), + html: Some("".to_owned()) + } + ) + } + + #[test] + fn test_read_yaml_none() { + let yaml_path = Path::new("./test_data/message_none.yaml"); + let res = Message::read_yaml(yaml_path); + assert!(res.is_ok()); + + let message = res.unwrap(); + assert_eq!( + message, + Message { + subject: "Test subject".to_owned(), + text: None, + html: None, + } + ) + } + + #[test] + fn test_personalize() { + let text = r#"Dear {first_name} {last_name}, +This is a test message (plaintext)."#; + let html = r#"Dear {first_name} {last_name}, +
+
+This is a test message (html)."#; + let mut message = Message::new( + "Test subject".to_owned(), + Some(text.to_owned()), + Some(html.to_owned()), + ); + message.personalize("first_name", "Marie"); + message.personalize("last_name", "Curie"); + assert_eq!( + message, + Message { + subject: "Test subject".to_owned(), + text: Some("Dear Marie Curie,\nThis is a test message (plaintext).".to_owned()), + html: Some( + "Dear Marie Curie,\n
\n
\nThis is a test message (html).".to_owned() + ) + } + ); + } + + #[test] + fn test_message_from_args_subject_content() { + let args = vec![ + "pigeon", + "send", + "albert@einstein.com", + "marie@curie.com", + "--subject", + "Test subject", + "--content", + "This is a test message (plaintext).", + ]; + let app = app(); + let matches = app.get_matches_from(args); + let subcommand_matches = matches.subcommand_matches("send").unwrap(); + + let res = Message::from_args(subcommand_matches); + assert!(res.is_ok(), "{}", res.unwrap_err()); + + let message = res.unwrap(); + assert_eq!( + message, + Message { + subject: "Test subject".to_owned(), + text: Some("This is a test message (plaintext).".to_owned()), + html: None, + } + ); + } + + #[test] + fn test_message_from_args_text_and_html_file() { + let args = vec![ + "pigeon", + "send", + "albert@einstein.com", + "marie@curie.com", + "--subject", + "Test subject", + "--text-file", + "./test_data/message.txt", + "--html-file", + "./test_data/message.html", + ]; + let app = app(); + let matches = app.get_matches_from(args); + let subcommand_matches = matches.subcommand_matches("send").unwrap(); + + let res = Message::from_args(subcommand_matches); + assert!(res.is_ok(), "{}", res.unwrap_err()); + + let message = res.unwrap(); + assert_eq!( + message, + Message { + subject: "Test subject".to_owned(), + text: Some( + "This is a test message (plaintext).\n\nThis is the last line.".to_owned() + ), + html: Some( + "

This is a test message (html).

\n\n

This is the last line.

" + .to_owned() + ), + } + ); + } + + #[test] + fn test_message_from_args_message_file() { + let args = vec![ + "pigeon", + "send", + "albert@einstein.com", + "marie@curie.com", + "--message-file", + "./test_data/message.yaml", + ]; + let app = app(); + let matches = app.get_matches_from(args); + let subcommand_matches = matches.subcommand_matches("send").unwrap(); + + let res = Message::from_args(subcommand_matches); + assert!(res.is_ok(), "{}", res.unwrap_err()); + + let message = res.unwrap(); + assert_eq!( + message, + Message { + subject: "Test subject".to_owned(), + text: Some("This is a test message (plaintext).".to_owned()), + html: Some("

This is a test message (html).

".to_owned()), + } + ); + } +} diff --git a/src/email_builder/message/message_template.rs b/src/email_builder/message/message_template.rs deleted file mode 100644 index 05f1da7..0000000 --- a/src/email_builder/message/message_template.rs +++ /dev/null @@ -1,109 +0,0 @@ -use crate::arg; -use anyhow::{anyhow, Context, Result}; -use clap::ArgMatches; -use serde::Deserialize; -use std::{ - env, fs, - io::{self, Write}, - path::PathBuf, -}; - -const TEMPLATE_FILE_NAME: &str = "message.yaml"; -static MESSAGE_TEMPLATE: &str = r##"# Specify the subject, plaintext and html version of your email. -# Personalize message by wrapping variables in curly brackets, eg. {first_name}. - -# The subject of your email -subject: "" -# The plaintext version -text: "" -# The html version -html: "" -"##; - -#[derive(Debug, Deserialize)] -pub struct MessageTemplate { - pub subject: String, - pub text: Option, - pub html: Option, -} - -impl MessageTemplate { - pub fn create(_matches: &ArgMatches) -> Result<(), anyhow::Error> { - let current_dir = env::current_dir().context("Can't get current directory")?; - let path_dir = current_dir; - - let file_name = TEMPLATE_FILE_NAME; - let template_path = path_dir.join(file_name); - - match template_path.exists() { - true => { - let mut input = String::new(); - println!("Message template already exists. Should this template be overwritten? Yes (y) or no (n)"); - loop { - io::stdin() - .read_line(&mut input) - .context("Can't read input")?; - match input.trim() { - "y" | "yes" | "Yes" => { - println!( - "Overwriting message template in current directory '{:#?}' ...", - template_path - ); - create_template(template_path)?; - break; - } - "n" | "no" | "No" => { - println!("Aborted ..."); - break; - } - _ => { - println!("Choose yes (y) or no (n). Try again."); - continue; - } - } - } - } - false => { - println!( - "Creating message template in current directory: {:#?} ...", - template_path - ); - create_template(template_path)?; - } - } - - Ok(()) - } - - pub fn read(matches: &ArgMatches) -> Result { - if matches.is_present(arg::MESSAGE_FILE) { - match matches.value_of(arg::MESSAGE_FILE) { - Some(message_file) => { - let path = PathBuf::from(message_file); - println!("Reading message file '{}' ...", path.display()); - let yaml = fs::read_to_string(&path)?; - let message = serde_yaml::from_str(&yaml)?; - - if matches.is_present(arg::DISPLAY) { - println!("Display message file: {:#?}", message); - } - - Ok(message) - } - None => Err(anyhow!( - "Missing value for argument '{}'", - arg::MESSAGE_FILE - )), - } - } else { - Err(anyhow!("Missing argument '{}'", arg::MESSAGE_FILE)) - } - } -} - -fn create_template(path: PathBuf) -> Result<(), anyhow::Error> { - let mut message_template = - fs::File::create(path).context("Unable to create message template.")?; - message_template.write_all(MESSAGE_TEMPLATE.as_bytes())?; - Ok(()) -} diff --git a/src/email_builder/message/mod.rs b/src/email_builder/message/mod.rs deleted file mode 100644 index e243adb..0000000 --- a/src/email_builder/message/mod.rs +++ /dev/null @@ -1,125 +0,0 @@ -mod message_template; -mod reader; - -pub use self::{message_template::MessageTemplate, reader::Reader}; -use crate::{arg, data_loader::TabularData}; -use anyhow::{anyhow, Result}; -use clap::ArgMatches; -use polars::prelude::DataFrame; - -#[derive(Debug, Clone)] -pub struct Message { - pub subject: String, - pub text: Option, - pub html: Option, -} - -impl Message { - pub fn build(matches: &ArgMatches) -> Result { - let message = if matches.is_present(arg::SUBJECT) && matches.is_present(arg::CONTENT) { - Message::from_cmd(matches)? - } else if matches.is_present(arg::MESSAGE_FILE) { - Message::from_template(matches)? - } else if matches.is_present(arg::SUBJECT) - && (matches.is_present(arg::TEXT_FILE) || matches.is_present(arg::HTML_FILE)) - { - Message::from_file(matches)? - } else { - return Err(anyhow!( - "Missing arguments. Please provide {} and {} or {}", - arg::SUBJECT, - arg::CONTENT, - arg::MESSAGE_FILE, - )); - }; - - Ok(message) - } - - pub fn from_file(matches: &ArgMatches) -> Result { - let subject = Message::subject(matches)?.to_string(); - let text = Reader::read_txt(matches)?; - let html = Reader::read_html(matches)?; - let message = Message::new(subject, text, html); - Ok(message) - } - - pub fn from_template(matches: &ArgMatches) -> Result { - let message_template = MessageTemplate::read(matches)?; - let message = Message::new( - message_template.subject, - message_template.text, - message_template.html, - ); - Ok(message) - } - - pub fn from_cmd(matches: &ArgMatches) -> Result { - match ( - matches.value_of(arg::SUBJECT), - matches.value_of(arg::CONTENT), - ) { - (Some(subject), Some(content)) => { - let message = Message::new(subject, Some(content), None); - Ok(message) - } - (Some(_), None) => Err(anyhow!("Missing value for argument '{}'", arg::CONTENT)), - (None, Some(_)) => Err(anyhow!("Missing value for argument '{}'", arg::SUBJECT)), - (None, None) => Err(anyhow!( - "Missing values for '{}' and '{}'", - arg::SUBJECT, - arg::CONTENT - )), - } - } - - pub fn personalize( - &mut self, - index: usize, - df_receiver: &DataFrame, - columns: &[&str], - ) -> Result<(), anyhow::Error> { - for &col_name in columns.iter() { - let col_value = TabularData::row(index, col_name, df_receiver)?; - self.replace(col_name, col_value); - } - - Ok(()) - } - - fn replace(&mut self, col_name: &str, col_value: &str) { - self.subject = self - .subject - .replace(&format!("{{{}}}", col_name), col_value); - self.text = self - .text - .as_ref() - .map(|text| text.replace(&format!("{{{}}}", col_name), col_value)); - self.html = self - .html - .as_ref() - .map(|html| html.replace(&format!("{{{}}}", col_name), col_value)); - } - - fn subject<'a>(matches: &'a ArgMatches) -> Result<&'a str, anyhow::Error> { - if matches.is_present(arg::SUBJECT) { - match matches.value_of(arg::SUBJECT) { - Some(subject) => Ok(subject), - None => Err(anyhow!("Missing value for argument '{}'", arg::SUBJECT)), - } - } else { - Err(anyhow!("Missing argument '{}'", arg::SUBJECT)) - } - } - - fn new(subject: S, text: Option, html: Option) -> Self - where - S: Into, - { - Self { - subject: subject.into(), - text: text.map(|text| text.into()), - html: html.map(|text| text.into()), - } - } -} diff --git a/src/email_builder/message/reader.rs b/src/email_builder/message/reader.rs deleted file mode 100644 index 96915c0..0000000 --- a/src/email_builder/message/reader.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::arg; -use anyhow::anyhow; -use clap::ArgMatches; -use std::{fs, path::Path}; - -#[derive(Debug)] -pub struct Reader; - -impl Reader { - pub fn read_txt(matches: &ArgMatches) -> Result, anyhow::Error> { - Self::read(matches, arg::TEXT_FILE) - } - - pub fn read_html(matches: &ArgMatches) -> Result, anyhow::Error> { - Self::read(matches, arg::HTML_FILE) - } - - fn read(matches: &ArgMatches, arg: &str) -> Result, anyhow::Error> { - if matches.is_present(arg) { - match matches.value_of(arg) { - Some(text_file) => { - let path = Path::new(text_file); - println!("Reading text file '{}' ...", path.display()); - let message = fs::read_to_string(path)?; - - if matches.is_present(arg::DISPLAY) { - println!("Display message file: {:#?}", message); - } - - Ok(Some(message)) - } - None => Err(anyhow!("Missing value for argument '{}'", arg)), - } - } else { - Ok(None) - } - } -} diff --git a/src/email_builder/mime.rs b/src/email_builder/mime.rs index 51c5ce9..b6b4c36 100644 --- a/src/email_builder/mime.rs +++ b/src/email_builder/mime.rs @@ -1,56 +1,65 @@ -use crate::{arg, email_builder}; +use super::{Receiver, Sender}; +use crate::email_builder; use anyhow::{anyhow, Context}; -use clap::ArgMatches; use lettre::{ message::{header, MultiPart, SinglePart}, - Message, + Message as LettreMessage, }; -use std::{fmt, fs, path::Path, str}; +use std::{fmt, fs, path::Path, str, time::SystemTime}; #[derive(Clone)] pub struct MimeFormat { - pub message: Message, + pub message: LettreMessage, } impl MimeFormat { pub fn new( - matches: &ArgMatches, - sender: &str, - receiver: &str, + sender: Sender, + receiver: Receiver, message: &email_builder::Message, + attachment: Option<&Path>, + now: SystemTime, ) -> Result { - let message_builder = Message::builder() - .from(sender.parse().context("Can't parse sender")?) - .to(receiver.parse().context("Can't parse receiver")?) - .subject(&message.subject); - - let message = match ( - &message.text, - &message.html, - matches.value_of(arg::ATTACHMENT), - ) { + let sender = sender.0.parse().context("Can't parse sender")?; + let receiver = receiver.0.parse().context("Can't parse receiver")?; + let message_builder = LettreMessage::builder() + .from(sender) + .to(receiver) + .subject(&message.subject) + .date(now); + let message = match (&message.text, &message.html, attachment) { (Some(text), Some(html), Some(attachment)) => message_builder.multipart( MultiPart::mixed() - .multipart(Self::alternative(text, html)) - .singlepart(Self::attachment(attachment)?), + .multipart( + MultiPart::alternative() + .singlepart(Self::singlepart_text_plain(text)) + .singlepart(Self::singlepart_text_html(html)), + ) + .singlepart(Self::singlepart_attachment(attachment)?), + ), + (Some(text), Some(html), None) => message_builder.multipart( + MultiPart::alternative() + .singlepart(Self::singlepart_text_plain(text)) + .singlepart(Self::singlepart_text_html(html)), ), - (Some(text), Some(html), None) => { - message_builder.multipart(Self::alternative(text, html)) - } (Some(text), None, Some(attachment)) => message_builder.multipart( MultiPart::mixed() - .singlepart(Self::text_plain(text)) - .singlepart(Self::attachment(attachment)?), + .singlepart(Self::singlepart_text_plain(text)) + .singlepart(Self::singlepart_attachment(attachment)?), ), (None, Some(html), Some(attachment)) => message_builder.multipart( MultiPart::mixed() - .singlepart(Self::text_html(html)) - .singlepart(Self::attachment(attachment)?), + .singlepart(Self::singlepart_text_html(html)) + .singlepart(Self::singlepart_attachment(attachment)?), ), - (Some(text), None, None) => message_builder.singlepart(Self::text_plain(text)), - (None, Some(html), None) => message_builder.singlepart(Self::text_html(html)), + (Some(text), None, None) => { + message_builder.singlepart(Self::singlepart_text_plain(text)) + } + (None, Some(html), None) => { + message_builder.singlepart(Self::singlepart_text_html(html)) + } (None, None, Some(attachment)) => { - message_builder.singlepart(Self::attachment(attachment)?) + message_builder.singlepart(Self::singlepart_attachment(attachment)?) } (None, None, None) => return Err(anyhow!("Missing email body")), } @@ -59,20 +68,19 @@ impl MimeFormat { Ok(Self { message }) } - fn text_plain(text: &str) -> SinglePart { + fn singlepart_text_plain(text: &str) -> SinglePart { SinglePart::builder() .header(header::ContentType::TEXT_PLAIN) .body(text.to_string()) } - fn text_html(text: &str) -> SinglePart { + fn singlepart_text_html(text: &str) -> SinglePart { SinglePart::builder() .header(header::ContentType::TEXT_HTML) .body(text.to_string()) } - fn attachment(file: &str) -> Result { - let path = Path::new(file); + fn singlepart_attachment(path: &Path) -> Result { let file_name = match path.file_name() { Some(file_name) => match file_name.to_str() { Some(file_name) => file_name, @@ -99,12 +107,6 @@ impl MimeFormat { .header(header::ContentDisposition::attachment(file_name)) .body(bytes)) } - - fn alternative(text: &str, html: &str) -> MultiPart { - MultiPart::alternative() - .singlepart(Self::text_plain(text)) - .singlepart(Self::text_html(html)) - } } impl fmt::Debug for MimeFormat { @@ -116,3 +118,237 @@ impl fmt::Debug for MimeFormat { ) } } + +#[cfg(test)] +mod tests { + use self::email_builder::Message; + use super::*; + use std::{fs::File, io::Read, time::UNIX_EPOCH}; + + impl MimeFormat { + pub fn new_with_boundaries( + sender: &str, + receiver: &str, + message: &email_builder::Message, + attachment: Option<&Path>, + now: SystemTime, + boundaries: Vec<&str>, + ) -> Result { + let sender = sender.parse().context("Can't parse sender")?; + let receiver = receiver.parse().context("Can't parse receiver")?; + let message_builder = LettreMessage::builder() + .from(sender) + .to(receiver) + .subject(&message.subject) + .date(now); + let message = match (&message.text, &message.html, attachment) { + (Some(text), Some(html), Some(attachment)) => message_builder.multipart( + MultiPart::mixed() + .boundary(boundaries[0]) + .multipart( + MultiPart::alternative() + .boundary(boundaries[1]) + .singlepart(Self::singlepart_text_plain(text)) + .singlepart(Self::singlepart_text_html(html)), + ) + .singlepart(Self::singlepart_attachment(attachment)?), + ), + (Some(text), Some(html), None) => message_builder.multipart( + MultiPart::alternative() + .boundary(boundaries[0]) + .singlepart(Self::singlepart_text_plain(text)) + .singlepart(Self::singlepart_text_html(html)), + ), + (Some(text), None, Some(attachment)) => message_builder.multipart( + MultiPart::mixed() + .boundary(boundaries[0]) + .singlepart(Self::singlepart_text_plain(text)) + .singlepart(Self::singlepart_attachment(attachment)?), + ), + (None, Some(html), Some(attachment)) => message_builder.multipart( + MultiPart::mixed() + .boundary(boundaries[0]) + .singlepart(Self::singlepart_text_html(html)) + .singlepart(Self::singlepart_attachment(attachment)?), + ), + (Some(text), None, None) => { + message_builder.singlepart(Self::singlepart_text_plain(text)) + } + (None, Some(html), None) => { + message_builder.singlepart(Self::singlepart_text_html(html)) + } + (None, None, Some(attachment)) => { + message_builder.singlepart(Self::singlepart_attachment(attachment)?) + } + (None, None, None) => return Err(anyhow!("Missing email body")), + } + .context("Can't create MIME formatted email")?; + + Ok(Self { message }) + } + } + + #[test] + fn test_mime_format_singlepart_plaintext() { + let date_time = chrono::DateTime::parse_from_rfc3339("2024-01-01T14:00:00Z") + .unwrap() + .timestamp() as u64; + let system_time = UNIX_EPOCH + std::time::Duration::from_secs(date_time); + let sender = Sender("albert@einstein.com"); + let receiver = Receiver("marie@curie.com"); + let subject = "Test Subject"; + let text = "This is a test message (plaintext)."; + let message = Message::new(subject, Some(text), None); + + let res = MimeFormat::new(sender, receiver, &message, None, system_time); + assert!(res.is_ok()); + + let mime_format = format!("{:?}", res.unwrap()); + let mut expected_file = File::open("./test_data/email_singlepart_plaintext.txt").unwrap(); + let mut expected_format = String::new(); + expected_file.read_to_string(&mut expected_format).unwrap(); + assert_eq!(mime_format.replace('\r', ""), expected_format); + } + + #[test] + fn test_mime_format_singlepart_html() { + let date_time = chrono::DateTime::parse_from_rfc3339("2024-01-01T14:00:00Z") + .unwrap() + .timestamp() as u64; + let system_time = UNIX_EPOCH + std::time::Duration::from_secs(date_time); + let sender = Sender("albert@einstein.com"); + let receiver = Receiver("marie@curie.com"); + let subject = "Test Subject"; + let html = "

This is a test message (html).

"; + let message = Message::new(subject, None, Some(html)); + + let res = MimeFormat::new(sender, receiver, &message, None, system_time); + assert!(res.is_ok()); + + let mime_format = format!("{:?}", res.unwrap()); + let mut expected_file = File::open("./test_data/email_singlepart_html.txt").unwrap(); + let mut expected_format = String::new(); + expected_file.read_to_string(&mut expected_format).unwrap(); + assert_eq!(mime_format.replace('\r', ""), expected_format); + } + + #[test] + fn test_mime_format_singlepart_attachment() { + let date_time = chrono::DateTime::parse_from_rfc3339("2024-01-01T14:00:00Z") + .unwrap() + .timestamp() as u64; + let system_time = UNIX_EPOCH + std::time::Duration::from_secs(date_time); + let sender = Sender("albert@einstein.com"); + let receiver = Receiver("marie@curie.com"); + let subject = "Test Subject"; + let message = Message::new(subject, None, None); + let attachment = Path::new("./test_data/test.txt"); + + let res = MimeFormat::new(sender, receiver, &message, Some(attachment), system_time); + assert!(res.is_ok()); + + let mime_format = format!("{:?}", res.unwrap()); + let mut expected_file = File::open("./test_data/email_singlepart_attachment.txt").unwrap(); + let mut expected_format = String::new(); + expected_file.read_to_string(&mut expected_format).unwrap(); + assert_eq!(mime_format.replace('\r', ""), expected_format); + } + + #[test] + fn test_mime_format_multipart_alternative() { + let date_time = chrono::DateTime::parse_from_rfc3339("2024-01-01T14:00:00Z") + .unwrap() + .timestamp() as u64; + let system_time = UNIX_EPOCH + std::time::Duration::from_secs(date_time); + let sender = "albert@einstein.com"; + let receiver = "marie@curie.com"; + let subject = "Test Subject"; + let text = "This is a test message (plaintext)."; + let html = "

This is a test message (html).

"; + let message = Message::new(subject, Some(text), Some(html)); + let boundaries = vec!["RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt"]; + + let res = MimeFormat::new_with_boundaries( + sender, + receiver, + &message, + None, + system_time, + boundaries, + ); + assert!(res.is_ok()); + + let mime_format = format!("{:?}", res.unwrap()); + let mut expected_file = File::open("./test_data/email_multipart_alternative.txt").unwrap(); + let mut expected_format = String::new(); + expected_file.read_to_string(&mut expected_format).unwrap(); + assert_eq!(mime_format.replace('\r', ""), expected_format); + } + + #[test] + fn test_mime_format_multipart_mixed() { + let date_time = chrono::DateTime::parse_from_rfc3339("2024-01-01T14:00:00Z") + .unwrap() + .timestamp() as u64; + let system_time = UNIX_EPOCH + std::time::Duration::from_secs(date_time); + let sender = "albert@einstein.com"; + let receiver = "marie@curie.com"; + let subject = "Test Subject"; + let text = "This is a test message (plaintext)."; + let message = Message::new(subject, Some(text), None); + let boundaries = vec!["RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt"]; + let attachment = Path::new("./test_data/test.txt"); + + let res = MimeFormat::new_with_boundaries( + sender, + receiver, + &message, + Some(attachment), + system_time, + boundaries, + ); + assert!(res.is_ok()); + + let mime_format = format!("{:?}", res.unwrap()); + let mut expected_file = File::open("./test_data/email_multipart_mixed.txt").unwrap(); + let mut expected_format = String::new(); + expected_file.read_to_string(&mut expected_format).unwrap(); + assert_eq!(mime_format.replace('\r', ""), expected_format); + } + + #[test] + fn test_mime_format_multipart_mixed_alternative() { + let date_time = chrono::DateTime::parse_from_rfc3339("2024-01-01T14:00:00Z") + .unwrap() + .timestamp() as u64; + let system_time = UNIX_EPOCH + std::time::Duration::from_secs(date_time); + let sender = "albert@einstein.com"; + let receiver = "marie@curie.com"; + let subject = "Test Subject"; + let text = "This is a test message (plaintext)."; + let html = "

This is a test message (html).

"; + let message = Message::new(subject, Some(text), Some(html)); + let boundaries = vec![ + "OTi56O3hPypBNfzLsCk053S1timfKY03AexmLpxU", + "HDKQ1fKhhPf7wLdMpdLSlteF05Rxv6VCIqIQf82I", + ]; + let attachment = Path::new("./test_data/test.txt"); + + let res = MimeFormat::new_with_boundaries( + sender, + receiver, + &message, + Some(attachment), + system_time, + boundaries, + ); + assert!(res.is_ok()); + + let mime_format = format!("{:?}", res.unwrap()); + let mut expected_file = + File::open("./test_data/email_multipart_mixed_alternative.txt").unwrap(); + let mut expected_format = String::new(); + expected_file.read_to_string(&mut expected_format).unwrap(); + assert_eq!(mime_format.replace('\r', ""), expected_format); + } +} diff --git a/src/email_builder/mod.rs b/src/email_builder/mod.rs index 5d6945c..4be9f97 100644 --- a/src/email_builder/mod.rs +++ b/src/email_builder/mod.rs @@ -1,15 +1,13 @@ -mod bulk_email; mod email; mod message; mod mime; mod receiver; mod sender; -pub use bulk_email::BulkEmail; -pub use email::Email; -pub use message::{Message, MessageTemplate}; +pub use email::{BulkEmail, Email}; +pub use message::Message; pub use mime::MimeFormat; -pub use receiver::Receiver; +pub use receiver::{BulkReceiver, Receiver}; pub use sender::Sender; pub enum Confirmed { diff --git a/src/email_builder/receiver.rs b/src/email_builder/receiver.rs index 46085ce..9b79173 100644 --- a/src/email_builder/receiver.rs +++ b/src/email_builder/receiver.rs @@ -1,74 +1,213 @@ -use crate::{arg, cmd, data_loader::TabularData}; -use anyhow::anyhow; +use crate::{ + arg, cmd, + sources::{self, ConnVars, DbConnection}, +}; +use anyhow::{anyhow, Context}; use clap::ArgMatches; -use polars::prelude::DataFrame; +use polars::{ + chunked_array::{ops::TakeRandom, ChunkedArray}, + datatypes::Utf8Type, + frame::DataFrame, +}; +use std::path::Path; -pub struct Receiver; +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Receiver<'a>(pub &'a str); -impl Receiver { - pub fn init<'a>(matches: &'a ArgMatches) -> Result<&'a str, anyhow::Error> { - if matches.is_present(arg::RECEIVER) { - match matches.value_of(arg::RECEIVER) { - Some(receiver) => Ok(receiver), - None => Err(anyhow!("Missing value for argument '{}'", arg::RECEIVER)), - } - } else { - Err(anyhow!("Missing argument '{}'", arg::RECEIVER)) +impl<'a> AsRef for Receiver<'a> { + fn as_ref(&self) -> &str { + self.0 + } +} + +#[derive(Debug, PartialEq)] +pub struct BulkReceiver { + pub column_name: String, + pub df_receiver: DataFrame, +} + +impl BulkReceiver { + pub fn new(column_name: String, df_receiver: DataFrame) -> Self { + Self { + column_name, + df_receiver, } } - pub fn dataframe(matches: &ArgMatches) -> Result { - match ( - matches.is_present(arg::RECEIVER_QUERY), - matches.is_present(arg::RECEIVER_FILE), - ) { - (true, false) => TabularData::from_query(matches), - (false, true) => TabularData::from_file(matches), - (true, true) => Err(anyhow!( + pub fn from_args(matches: &ArgMatches) -> Result { + let column_name = arg::value(arg::RECEIVER_COLUMN, matches)?; + let receiver_query = matches.value_of(arg::RECEIVER_QUERY); + let receiver_path = matches.value_of(arg::RECEIVER_FILE).map(Path::new); + + match (receiver_query, receiver_path) { + (Some(query), None) => { + let conn_vars = ConnVars::from_env()?; + let ssh_tunnel = matches.value_of(arg::SSH_TUNNEL); + let connection = DbConnection::new(&conn_vars, ssh_tunnel)?; + let df_receiver = sources::query_postgres(&connection, query)?; + + if matches.is_present(arg::DISPLAY) { + println!("Display query result: {}", df_receiver); + } + + Ok(Self::new( + column_name.to_owned() , + df_receiver, + )) + }, + (None, Some(path)) => { + let df_receiver = sources::read_csv(path)?; + + if matches.is_present(arg::DISPLAY) { + println!("Display csv file: {}", df_receiver); + } + + Ok(Self::new( + column_name.to_owned() , + df_receiver, + )) + }, + (Some(_), Some(_)) => { + Err(anyhow!( "Argument conflict: arguments {} and {} are not allowed at the same time. Check usage via '{} help {}'", arg::RECEIVER_QUERY, arg::RECEIVER_FILE, cmd::BIN, cmd::SEND_BULK, - )), - (false, false) => Err(anyhow!( + )) + }, + (None, None) => { + Err(anyhow!( "Missing arguments: please specify argument {} or {}. Check usage via '{} help {}'", arg::RECEIVER_QUERY, arg::RECEIVER_FILE, cmd::BIN, cmd::SEND_BULK, - )), + )) + }, } } - pub fn column_name<'a>(matches: &'a ArgMatches<'a>) -> Result<&str, anyhow::Error> { - // If argument 'RECEIVER_COLUMN' is not present the default value 'email' will be used as column name - match matches.value_of(arg::RECEIVER_COLUMN) { - Some(column_name) => Ok(column_name), - None => Err(anyhow!( - "Missing value for argument '{}'", - arg::RECEIVER_COLUMN - )), - } + pub fn height(&self) -> usize { + self.df_receiver.height() } - pub fn file_name<'a>(matches: &'a ArgMatches<'a>) -> Result<&str, anyhow::Error> { - match matches.value_of(arg::RECEIVER_FILE) { - Some(file_name) => Ok(file_name), - None => Err(anyhow!( - "Missing value for argument '{}'", - arg::RECEIVER_FILE - )), - } + pub fn receiver_column(&self) -> Result<&ChunkedArray, anyhow::Error> { + self.column(&self.column_name) } - pub fn query<'a>(matches: &'a ArgMatches<'a>) -> Result<&str, anyhow::Error> { - match matches.value_of(arg::RECEIVER_QUERY) { - Some(query) => Ok(query), + pub fn receiver_row(&self, index: usize) -> Result<&str, anyhow::Error> { + self.row(index, &self.column_name) + } + + pub fn row<'a>(&'a self, index: usize, column_name: &str) -> Result<&'a str, anyhow::Error> { + let column_value = self + .df_receiver + .column(column_name) + .context(format!("Missing column '{}'", column_name))? + .utf8() + .context("Can't convert series to chunked array")? + .get(index); + + match column_value { + Some(column_value) => Ok(column_value), None => Err(anyhow!( - "Missing value for argument '{}'", - arg::RECEIVER_QUERY + "Missing value for column '{}' in row {}", + column_name, + index )), } } + + pub fn column<'a>( + &'a self, + column_name: &str, + ) -> Result<&'a ChunkedArray, anyhow::Error> { + let column = self + .df_receiver + .column(column_name) + .context(format!("Missing column '{column_name}'"))? + .utf8() + .context("Can't convert series to chunked array")?; + + Ok(column) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app; + use polars::{prelude::NamedFrom, series::Series}; + + #[test] + fn test_bulk_receiver_from_args_receiver_file() { + let args = vec![ + "pigeon", + "send-bulk", + "albert@einstein.com", + "--receiver-file", + "./test_data/receiver.csv", + "--message-file", + "./test_data/message.yaml", + ]; + let app = app(); + let matches = app.get_matches_from(args); + let subcommand_matches = matches.subcommand_matches("send-bulk").unwrap(); + + let res = BulkReceiver::from_args(subcommand_matches); + assert!(res.is_ok(), "{}", res.unwrap_err()); + + let actual = res.unwrap(); + let first_name_column = Series::new("first_name", &["Marie", "Alexandre"]); + let last_name_column = Series::new("last_name", &["Curie", "Grothendieck"]); + let email_column = Series::new("email", &["marie@curie.com", "alexandre@grothendieck.com"]); + let expected = + DataFrame::new(vec![first_name_column, last_name_column, email_column]).unwrap(); + assert_eq!( + actual, + BulkReceiver { + column_name: "email".to_owned(), + df_receiver: expected + } + ); + } + + #[test] + fn test_bulk_receiver_from_args_receiver_column() { + let args = vec![ + "pigeon", + "send-bulk", + "albert@einstein.com", + "--receiver-file", + "./test_data/contacts.csv", + "--message-file", + "./test_data/message.yaml", + "--receiver-column", + "contact", + ]; + let app = app(); + let matches = app.get_matches_from(args); + let subcommand_matches = matches.subcommand_matches("send-bulk").unwrap(); + + let res = BulkReceiver::from_args(subcommand_matches); + assert!(res.is_ok(), "{}", res.unwrap_err()); + + let actual = res.unwrap(); + let first_name_column = Series::new("first_name", &["Marie", "Alexandre"]); + let last_name_column = Series::new("last_name", &["Curie", "Grothendieck"]); + let email_column = Series::new( + "contact", + &["marie@curie.com", "alexandre@grothendieck.com"], + ); + let expected = + DataFrame::new(vec![first_name_column, last_name_column, email_column]).unwrap(); + assert_eq!( + actual, + BulkReceiver { + column_name: "contact".to_owned(), + df_receiver: expected + } + ); + } } diff --git a/src/email_builder/sender.rs b/src/email_builder/sender.rs index b92a7dc..14ba161 100644 --- a/src/email_builder/sender.rs +++ b/src/email_builder/sender.rs @@ -1,18 +1,8 @@ -use crate::arg; -use anyhow::anyhow; -use clap::ArgMatches; +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Sender<'a>(pub &'a str); -pub struct Sender; - -impl Sender { - pub fn init<'a>(matches: &'a ArgMatches) -> Result<&'a str, anyhow::Error> { - if matches.is_present(arg::SENDER) { - match matches.value_of(arg::SENDER) { - Some(sender) => Ok(sender), - None => Err(anyhow!("Missing value for argument '{}'", arg::SENDER)), - } - } else { - Err(anyhow!("Missing argument '{}'", arg::SENDER)) - } +impl<'a> AsRef for Sender<'a> { + fn as_ref(&self) -> &str { + self.0 } } diff --git a/src/email_formatter/eml.rs b/src/email_formatter/eml.rs index 3089950..5e2498c 100644 --- a/src/email_formatter/eml.rs +++ b/src/email_formatter/eml.rs @@ -1,52 +1,51 @@ -use crate::{arg, email_builder::Email}; -use anyhow::{anyhow, Context}; -use clap::ArgMatches; +use crate::email_builder::Email; +use anyhow::Context; +use chrono::{DateTime, Utc}; use lettre::{FileTransport, Transport}; use std::{ fs, path::{Path, PathBuf}, }; -pub struct EmlFormatter<'a> { - target_dir: &'a Path, +/// Structure to store emails in EML format. +pub struct EmlFormatter { + target_dir: PathBuf, transport: FileTransport, } -impl<'a> EmlFormatter<'a> { - pub fn new(matches: &'a ArgMatches<'a>) -> Result { - let target_dir = match matches.value_of(arg::ARCHIVE_DIR) { - Some(archive_dir) => Path::new(archive_dir), - None => return Err(anyhow!("Missing value for argument '{}'", arg::ARCHIVE_DIR)), - }; - +impl EmlFormatter { + pub fn new(target_dir: &Path) -> Result { if !target_dir.exists() { fs::create_dir(target_dir).context("Unable to create directory for archived emails")?; } let transport = FileTransport::new(target_dir); let formatter = Self { - target_dir, + target_dir: target_dir.to_owned(), transport, }; Ok(formatter) } - pub fn archive(&self, matches: &ArgMatches, email: &Email) -> Result<(), anyhow::Error> { - if matches.is_present(arg::ARCHIVE) { - let message_id = self - .transport - .send(&email.mime_format.message) - .context("Can't save email in .eml format")?; + pub fn archive( + &self, + email: &Email, + now: DateTime, + dry_run: bool, + ) -> Result<(), anyhow::Error> { + let message_id = self + .transport + .send(&email.mime_format.message) + .context("Can't save email in .eml format")?; - let old_path = old_path(message_id.as_str(), self.target_dir); - let new_path = new_path(matches, message_id.as_str(), self.target_dir); + let old_path = old_path(message_id.as_str(), &self.target_dir); + let new_path = new_path(message_id.as_str(), &self.target_dir, dry_run, now); - println!("Archiving '{}' ...", new_path.display()); + println!("Archiving '{}' ...", new_path.display()); - // TODO: renaming file is required because of issue/discussion https://github.com/lettre/lettre/discussions/711 - fs::rename(old_path, new_path).context("Can't rename archived email")?; - } + // TODO: renaming file is required because of issue/discussion https://github.com/lettre/lettre/discussions/711 + fs::rename(old_path, new_path).context("Can't rename archived email")?; Ok(()) } @@ -57,12 +56,10 @@ fn old_path(message_id: &str, target_dir: &Path) -> PathBuf { target_dir.join(old_file_name) } -fn new_path(matches: &ArgMatches, message_id: &str, target_dir: &Path) -> PathBuf { - let now = std::time::SystemTime::now(); - let now_utc: chrono::DateTime = now.into(); - let timestamp = now_utc.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); +fn new_path(message_id: &str, target_dir: &Path, dry_run: bool, now: DateTime) -> PathBuf { + let timestamp = now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let new_file_name = if matches.is_present(arg::DRY_RUN) { + let new_file_name = if dry_run { format!("{}_{}_dry-run.eml", timestamp, message_id) } else { format!("{}_{}.eml", timestamp, message_id) @@ -70,3 +67,77 @@ fn new_path(matches: &ArgMatches, message_id: &str, target_dir: &Path) -> PathBu target_dir.join(new_file_name) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::email_builder::{Message, MimeFormat, Receiver, Sender}; + use std::time::{Duration, SystemTime}; + use tempfile::tempdir; + + fn create_email<'a>(now: SystemTime) -> Email<'a> { + let sender = Sender("albert@einstein.com"); + let receiver = Receiver("marie@curie.com"); + let subject = "Test subject"; + let text = "This is a test message (plaintext)."; + let html = "

This is a test message (html).

"; + let message = Message::new(subject, Some(text), Some(html)); + let mime_format = MimeFormat::new(sender, receiver, &message, None, now).unwrap(); + Email::new(sender, receiver, &message, &mime_format).unwrap() + } + + #[test] + fn test_archive() { + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + let now = Utc::now(); + let now_system_time = SystemTime::UNIX_EPOCH + Duration::from_secs(now.timestamp() as u64); + let dry_run = false; + let email = create_email(now_system_time); + + let email_formatter = EmlFormatter::new(temp_path).unwrap(); + let res = email_formatter.archive(&email, now, dry_run); + assert!(res.is_ok(), "{}", res.unwrap_err()); + + if let Ok(entries) = fs::read_dir(temp_path) { + let files = entries.flatten().collect::>(); + assert_eq!(files.len(), 1); + + let path = &files[0].path(); + assert_eq!(path.extension().unwrap().to_str().unwrap(), "eml"); + + let eml = fs::read_to_string(path).unwrap(); + assert_eq!(eml, format!("{:?}", email.mime_format)); + } + } + + #[test] + fn test_archive_dry() { + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + let now = Utc::now(); + let now_system_time = SystemTime::UNIX_EPOCH + Duration::from_secs(now.timestamp() as u64); + let dry_run = true; + let email = create_email(now_system_time); + + let email_formatter = EmlFormatter::new(temp_path).unwrap(); + let res = email_formatter.archive(&email, now, dry_run); + assert!(res.is_ok(), "{}", res.unwrap_err()); + + if let Ok(entries) = fs::read_dir(temp_path) { + let files = entries.flatten().collect::>(); + assert_eq!(files.len(), 1); + + let path = &files[0].path(); + assert_eq!(path.extension().unwrap().to_str().unwrap(), "eml"); + assert!(path.to_str().unwrap().contains("dry-run")); + + let eml = fs::read_to_string(path).unwrap(); + assert_eq!(eml, format!("{:?}", email.mime_format)); + } + } +} diff --git a/src/email_provider/aws.rs b/src/email_provider/aws.rs index 83db8d3..1643c42 100644 --- a/src/email_provider/aws.rs +++ b/src/email_provider/aws.rs @@ -2,7 +2,7 @@ use crate::{ arg, email_builder::Email, email_transmission::{SendEmail, SentEmail, Status}, - helper::format_green, + utils::format_green, }; use anyhow::{Context, Result}; use bytes::Bytes; @@ -57,29 +57,20 @@ impl AwsSesClient { impl<'a> SendEmail<'a> for AwsSesClient { #[tokio::main] - async fn send( - &self, - matches: &ArgMatches, - email: &'a Email<'a>, - ) -> Result, anyhow::Error> { - let sent_email = if matches.is_present(arg::DRY_RUN) { - let status = Status::DryRun; - SentEmail::new(email, status) - } else { - let raw_message = RawMessage { - data: Bytes::from(base64::encode(email.mime_format.message.formatted())), - }; - let request = SendRawEmailRequest { - raw_message, - ..Default::default() - }; - let response = self.client.send_raw_email(request).await; - let status = match response { - Ok(response) => Status::SentOk(response.message_id), - Err(err) => Status::SentError(err.to_string()), - }; - SentEmail::new(email, status) + async fn send(&self, email: &'a Email<'a>) -> Result, anyhow::Error> { + let raw_message = RawMessage { + data: Bytes::from(base64::encode(email.mime_format.message.formatted())), }; + let request = SendRawEmailRequest { + raw_message, + ..Default::default() + }; + let response = self.client.send_raw_email(request).await; + let status = match response { + Ok(response) => Status::SentOk(response.message_id), + Err(err) => Status::SentError(err.to_string()), + }; + let sent_email = SentEmail::new(email, status); Ok(sent_email) } diff --git a/src/email_transmission/client.rs b/src/email_transmission/client.rs index 72a22a8..547fe3d 100644 --- a/src/email_transmission/client.rs +++ b/src/email_transmission/client.rs @@ -1,4 +1,4 @@ -use super::{SentEmail, SmtpClient}; +use super::{MockClient, SendEmail, SentEmail, SmtpClient}; use crate::{ arg::{self, val}, email_builder::Email, @@ -6,43 +6,95 @@ use crate::{ }; use anyhow::anyhow; use clap::ArgMatches; +use std::fmt; -pub trait SendEmail<'a> { - fn send( - &self, - matches: &ArgMatches, - email: &'a Email<'a>, - ) -> Result, anyhow::Error>; +#[derive(Debug, PartialEq)] +pub enum TransmissionType { + Smtp, + Aws, + Dry, } -pub struct Client; - -impl Client { - pub fn init<'a>(matches: &ArgMatches) -> Result>, anyhow::Error> { - if matches.is_present(arg::CONNECTION) { - match matches.value_of(arg::CONNECTION) { - Some(connection) => match connection.to_lowercase().as_str() { - val::SMTP => { - let client = SmtpClient::new()?; - Ok(Box::new(client)) - } - val::AWS => { - let client = AwsSesClient::new(matches)?; - Ok(Box::new(client)) - } - other => Err(anyhow!(format!( - "Value '{}' for argument '{}' not supported", - other, - arg::CONNECTION - ))), - }, - None => Err(anyhow!(format!( - "Missing value for argument '{}'", - arg::CONNECTION - ))), +impl fmt::Display for TransmissionType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let request_method = match self { + Self::Smtp => "smtp", + Self::Aws => "aws", + Self::Dry => "dry", + }; + + write!(f, "{}", request_method) + } +} + +pub struct Client<'a> { + #[allow(dead_code)] + transmission_type: TransmissionType, + client: Box>, +} + +impl<'a> Client<'a> { + pub fn new(transmission_type: TransmissionType, client: Box>) -> Self { + Self { + transmission_type, + client, + } + } + + pub fn from_args(matches: &ArgMatches) -> Result { + if matches.is_present(arg::DRY_RUN) { + let client = MockClient; + return Ok(Client::new(TransmissionType::Dry, Box::new(client))); + } + + let connection = arg::value(arg::CONNECTION, matches)?; + + match connection.to_lowercase().as_str() { + val::SMTP => { + let client = SmtpClient::new()?; + Ok(Client::new(TransmissionType::Smtp, Box::new(client))) + } + val::AWS => { + let client = AwsSesClient::new(matches)?; + Ok(Client::new(TransmissionType::Aws, Box::new(client))) } - } else { - Err(anyhow!(format!("Missing argument '{}'", arg::CONNECTION))) + other => Err(anyhow!(format!( + "Value '{}' for argument '{}' not supported", + other, + arg::CONNECTION + ))), } } + + pub fn send(&self, email: &'a Email<'a>) -> Result, anyhow::Error> { + self.client.send(email) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app; + + #[test] + fn test_client_from_args_dry() { + let args = vec![ + "pigeon", + "send", + "albert@einstein.com", + "marie@curie.com", + "--message-file", + "./test_data/message.yaml", + "--dry-run", + ]; + let app = app(); + let matches = app.get_matches_from(args); + let subcommand_matches = matches.subcommand_matches("send").unwrap(); + + let res = Client::from_args(subcommand_matches); + assert!(res.is_ok()); + + let client = res.unwrap(); + assert_eq!(client.transmission_type, TransmissionType::Dry); + } } diff --git a/src/email_transmission/mock_client.rs b/src/email_transmission/mock_client.rs new file mode 100644 index 0000000..b02a6ab --- /dev/null +++ b/src/email_transmission/mock_client.rs @@ -0,0 +1,12 @@ +use super::{SendEmail, SentEmail, Status}; +use crate::email_builder::Email; + +#[derive(PartialEq)] +pub struct MockClient; + +impl<'a> SendEmail<'a> for MockClient { + fn send(&self, email: &'a Email<'a>) -> Result, anyhow::Error> { + let email = SentEmail::new(email, Status::DryRun); + Ok(email) + } +} diff --git a/src/email_transmission/mod.rs b/src/email_transmission/mod.rs index f65de05..939f015 100644 --- a/src/email_transmission/mod.rs +++ b/src/email_transmission/mod.rs @@ -1,9 +1,16 @@ mod client; +mod mock_client; mod sent_email; mod smtp; mod status; -pub use client::{Client, SendEmail}; +use crate::email_builder::Email; +pub use client::Client; +pub use mock_client::MockClient; pub use sent_email::SentEmail; pub use smtp::SmtpClient; pub use status::Status; + +pub trait SendEmail<'a> { + fn send(&self, email: &'a Email<'a>) -> Result, anyhow::Error>; +} diff --git a/src/email_transmission/sent_email.rs b/src/email_transmission/sent_email.rs index 24dcb98..0d8bb9f 100644 --- a/src/email_transmission/sent_email.rs +++ b/src/email_transmission/sent_email.rs @@ -1,10 +1,10 @@ use super::Status; -use crate::email_builder::{Email, Message}; +use crate::email_builder::{Email, Message, Receiver, Sender}; #[derive(Debug)] pub struct SentEmail<'a> { - pub sender: &'a str, - pub receiver: &'a str, + pub sender: Sender<'a>, + pub receiver: Receiver<'a>, pub message: &'a Message, pub status: Status, } @@ -20,6 +20,6 @@ impl<'a> SentEmail<'a> { } pub fn display_status(&self) { - println!("{} ... {}", self.receiver, self.status); + println!("{} ... {}", self.receiver.0, self.status); } } diff --git a/src/email_transmission/smtp.rs b/src/email_transmission/smtp.rs index d475924..92189c8 100644 --- a/src/email_transmission/smtp.rs +++ b/src/email_transmission/smtp.rs @@ -1,11 +1,9 @@ -use super::{client::SendEmail, SentEmail, Status}; +use super::{SendEmail, SentEmail, Status}; use crate::{ - arg, email_builder::Email, - helper::{format_green, format_red}, + utils::{format_green, format_red}, }; use anyhow::Context; -use clap::ArgMatches; use lettre::{transport::smtp::authentication::Credentials, SmtpTransport, Transport}; use std::env; @@ -48,30 +46,21 @@ impl SmtpClient { } impl<'a> SendEmail<'a> for SmtpClient { - fn send( - &self, - matches: &ArgMatches, - email: &'a Email<'a>, - ) -> Result, anyhow::Error> { - let sent_email = if matches.is_present(arg::DRY_RUN) { - let status = Status::DryRun; - SentEmail::new(email, status) - } else { - let response = self - .transport - .send(&email.mime_format.message) - .context("Can't sent email via SMTP"); - let status = match response { - Ok(response) => { - let response_string = response.message().collect::(); - let messages: Vec<&str> = response_string.split(' ').collect(); - let message_id = messages[1]; - Status::SentOk(message_id.to_string()) - } - Err(err) => Status::SentError(err.to_string()), - }; - SentEmail::new(email, status) + fn send(&self, email: &'a Email<'a>) -> Result, anyhow::Error> { + let response = self + .transport + .send(&email.mime_format.message) + .context("Can't sent email via SMTP"); + let status = match response { + Ok(response) => { + let response_string = response.message().collect::(); + let messages: Vec<&str> = response_string.split(' ').collect(); + let message_id = messages[1]; + Status::SentOk(message_id.to_string()) + } + Err(err) => Status::SentError(err.to_string()), }; + let sent_email = SentEmail::new(email, status); Ok(sent_email) } diff --git a/src/email_transmission/status.rs b/src/email_transmission/status.rs index 2c0cdd8..c601305 100644 --- a/src/email_transmission/status.rs +++ b/src/email_transmission/status.rs @@ -1,4 +1,4 @@ -use crate::helper::{format_green, format_red}; +use crate::utils::{format_green, format_red}; use std::fmt; #[derive(Debug, Clone)] diff --git a/src/lib.rs b/src/lib.rs index 2c46d3a..79fc7b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,14 @@ pub mod arg; pub mod cmd; -mod data_loader; -mod data_sources; mod email_builder; mod email_formatter; mod email_provider; mod email_transmission; -mod helper; +mod sources; +mod utils; -use clap::{crate_name, crate_version, App, SubCommand}; +use arg::val; +use clap::{crate_name, crate_version, App, Arg, SubCommand}; pub fn app() -> App<'static, 'static> { App::new(crate_name!()) @@ -22,36 +22,300 @@ pub fn app() -> App<'static, 'static> { .subcommand( SubCommand::with_name(cmd::INIT) .about("Create template files in current directory") - .args(&cmd::init_args()), + .args(&[Arg::with_name(arg::VERBOSE) + .long(arg::VERBOSE) + .takes_value(false) + .help("Shows what is going on for subcommand")]), ) .subcommand( SubCommand::with_name(cmd::CONNECT) .about("Check connection to SMTP server or email provider") - .args(&cmd::connect_args()), + .args(&[ + Arg::with_name(cmd::CONNECT) + .takes_value(true) + .possible_values(&[val::SMTP, val::AWS]) + .default_value(val::SMTP) + .help("Check connection to SMTP server."), + Arg::with_name(arg::VERBOSE) + .long(arg::VERBOSE) + .takes_value(false) + .help("Shows what is going on for subcommand"), + ]), ) .subcommand( SubCommand::with_name(cmd::QUERY) .about("Query database and display results in terminal (select statements only)") - .args(&cmd::query_args()), + .args(&[ + Arg::with_name(cmd::QUERY) + .index(1) + .required(true) + .takes_value(true) + .help("Takes a sql query"), + Arg::with_name(arg::SSH_TUNNEL) + .long(arg::SSH_TUNNEL) + .value_name("port") + .takes_value(true) + .help("Connect to db through ssh tunnel"), + Arg::with_name(arg::SAVE) + .long(arg::SAVE) + .takes_value(false) + .help("Save query result"), + Arg::with_name(arg::SAVE_DIR) + .long(arg::SAVE_DIR) + .takes_value(true) + .default_value("./saved_queries") + .help("Specifies the output directory for saved query"), + Arg::with_name(arg::FILE_TYPE) + .long(arg::FILE_TYPE) + .takes_value(true) + .default_value("csv") + .possible_values(&["csv", "jpg", "png"]) + .help("Specifies the file type for saved query"), + Arg::with_name(arg::IMAGE_COLUMN) + .long(arg::IMAGE_COLUMN) + .required_ifs(&[(arg::FILE_TYPE, "jpg"), (arg::FILE_TYPE, "png")]) + .takes_value(true) + .help("Specifies the column in which to look for images"), + Arg::with_name(arg::IMAGE_NAME) + .long(arg::IMAGE_NAME) + .required_ifs(&[(arg::FILE_TYPE, "jpg"), (arg::FILE_TYPE, "png")]) + .takes_value(true) + .help("Specifies the column used for the image name"), + Arg::with_name(arg::DISPLAY) + .long(arg::DISPLAY) + .takes_value(false) + .help("Print query result to terminal"), + Arg::with_name(arg::VERBOSE) + .long(arg::VERBOSE) + .takes_value(false) + .help("Shows what is going on for subcommand"), + ]), ) .subcommand( SubCommand::with_name(cmd::SIMPLE_QUERY) .about("Simple query using the simple query protocol") - .args(&cmd::simple_query_args()), + .args(&[ + Arg::with_name(cmd::SIMPLE_QUERY) + .index(1) + .required(true) + .takes_value(true) + .help("Takes a sql query"), + Arg::with_name(arg::VERBOSE) + .long(arg::VERBOSE) + .takes_value(false) + .help("Shows what is going on for subcommand"), + ]), ) .subcommand( SubCommand::with_name(cmd::READ) .about("Read csv file and display results in terminal") - .args(&cmd::read_args()), + .args(&[ + Arg::with_name(cmd::READ).required(true).takes_value(true), + Arg::with_name(arg::VERBOSE) + .long(arg::VERBOSE) + .takes_value(false) + .help("Shows what is going on for subcommand"), + Arg::with_name(arg::DISPLAY) + .long(arg::DISPLAY) + .takes_value(false) + .help("Display csv file in terminal"), + ]), ) .subcommand( SubCommand::with_name(cmd::SEND) .about("Send email to single recipient") - .args(&cmd::send_args()), + .args(&[ + Arg::with_name(arg::SENDER) + .index(1) + .required(true) + .takes_value(true) + .requires_all(&[arg::RECEIVER]) + .help("Email address of the sender"), + Arg::with_name(arg::RECEIVER) + .index(2) + .required(true) + .takes_value(true) + .requires_all(&[arg::SENDER]) + .help("Email address of the receiver"), + Arg::with_name(arg::SUBJECT) + .long(arg::SUBJECT) + .takes_value(true) + .required_unless_one(&[arg::MESSAGE_FILE]) + .help("Subject of the email"), + Arg::with_name(arg::CONTENT) + .long(arg::CONTENT) + .takes_value(true) + .requires(arg::SUBJECT) + .required_unless_one(&[arg::MESSAGE_FILE, arg::TEXT_FILE, arg::HTML_FILE]) + .conflicts_with_all(&[arg::MESSAGE_FILE, arg::TEXT_FILE, arg::HTML_FILE]) + .help("Content of the email"), + Arg::with_name(arg::MESSAGE_FILE) + .long(arg::MESSAGE_FILE) + .takes_value(true) + .required_unless_one(&[ + arg::SUBJECT, + arg::CONTENT, + arg::TEXT_FILE, + arg::HTML_FILE, + ]) + .conflicts_with_all(&[arg::CONTENT, arg::TEXT_FILE, arg::HTML_FILE]) + .help("Path of the message file"), + Arg::with_name(arg::TEXT_FILE) + .long(arg::TEXT_FILE) + .takes_value(true) + .requires(arg::SUBJECT) + .conflicts_with_all(&[arg::CONTENT, arg::MESSAGE_FILE]) + .help("Path of text file"), + Arg::with_name(arg::HTML_FILE) + .long(arg::HTML_FILE) + .takes_value(true) + .requires(arg::SUBJECT) + .conflicts_with_all(&[arg::CONTENT, arg::MESSAGE_FILE]) + .help("Path of html file"), + Arg::with_name(arg::ATTACHMENT) + .long(arg::ATTACHMENT) + .takes_value(true) + .help("Path of attachment"), + Arg::with_name(arg::ARCHIVE) + .long(arg::ARCHIVE) + .takes_value(false) + .help("Archive sent emails"), + Arg::with_name(arg::ARCHIVE_DIR) + .long(arg::ARCHIVE_DIR) + .takes_value(true) + .default_value("./sent_emails") + .help("Path of sent emails"), + Arg::with_name(arg::DISPLAY) + .long(arg::DISPLAY) + .takes_value(false) + .help("Display email in terminal"), + Arg::with_name(arg::DRY_RUN) + .long(arg::DRY_RUN) + .takes_value(false) + .help("Prepare email but do not send email"), + Arg::with_name(arg::ASSUME_YES) + .long(arg::ASSUME_YES) + .takes_value(false) + .help("Send email without confirmation"), + Arg::with_name(arg::CONNECTION) + .long(arg::CONNECTION) + .takes_value(true) + .possible_values(&[val::SMTP, val::AWS]) + .default_value(val::SMTP) + .help("Send emails via SMTP or AWS API"), + Arg::with_name(arg::VERBOSE) + .long(arg::VERBOSE) + .takes_value(false) + .help("Shows what is going on for subcommand"), + ]), ) .subcommand( SubCommand::with_name(cmd::SEND_BULK) .about("Send email to multiple recipients") - .args(&cmd::send_bulk_args()), + .args(&[ + Arg::with_name(arg::SENDER) + .index(1) + .required(true) + .takes_value(true) + .help("Email address of the sender"), + Arg::with_name(arg::RECEIVER_FILE) + .long(arg::RECEIVER_FILE) + .required_unless(arg::RECEIVER_QUERY) + .takes_value(true) + .help( + "Email addresses of multiple receivers fetched from provided csv file", + ), + Arg::with_name(arg::RECEIVER_QUERY) + .long(arg::RECEIVER_QUERY) + .required_unless(arg::RECEIVER_FILE) + .takes_value(true) + .help("Email addresses of multiple receivers fetched from provided query"), + Arg::with_name(arg::SUBJECT) + .long(arg::SUBJECT) + .takes_value(true) + .required_unless_one(&[arg::MESSAGE_FILE]) + .help("Subject of the email"), + Arg::with_name(arg::CONTENT) + .long(arg::CONTENT) + .takes_value(true) + .requires(arg::SUBJECT) + .required_unless_one(&[arg::MESSAGE_FILE, arg::TEXT_FILE, arg::HTML_FILE]) + .conflicts_with_all(&[arg::MESSAGE_FILE, arg::TEXT_FILE, arg::HTML_FILE]) + .help("Content of the email"), + Arg::with_name(arg::MESSAGE_FILE) + .long(arg::MESSAGE_FILE) + .takes_value(true) + .required_unless_one(&[ + arg::SUBJECT, + arg::CONTENT, + arg::TEXT_FILE, + arg::HTML_FILE, + ]) + .conflicts_with_all(&[arg::CONTENT, arg::TEXT_FILE, arg::HTML_FILE]) + .help("Path of the message file"), + Arg::with_name(arg::TEXT_FILE) + .long(arg::TEXT_FILE) + .takes_value(true) + .requires(arg::SUBJECT) + .conflicts_with_all(&[arg::CONTENT, arg::MESSAGE_FILE]) + .help("Path of text file"), + Arg::with_name(arg::HTML_FILE) + .long(arg::HTML_FILE) + .takes_value(true) + .requires(arg::SUBJECT) + .conflicts_with_all(&[arg::CONTENT, arg::MESSAGE_FILE]) + .help("Path of html file"), + Arg::with_name(arg::ATTACHMENT) + .long(arg::ATTACHMENT) + .takes_value(true) + .help("Path of attachment"), + Arg::with_name(arg::ARCHIVE) + .long(arg::ARCHIVE) + .takes_value(false) + .help("Archive sent emails"), + Arg::with_name(arg::ARCHIVE_DIR) + .long(arg::ARCHIVE_DIR) + .takes_value(true) + .default_value("./sent_emails") + .help("Path of sent emails"), + Arg::with_name(arg::RECEIVER_COLUMN) + .long(arg::RECEIVER_COLUMN) + .takes_value(true) + .default_value(val::EMAIL) + .help("Specifies the column in which to look for email addresses"), + Arg::with_name(arg::PERSONALIZE) + .long(arg::PERSONALIZE) + .takes_value(true) + .multiple(true) + .max_values(100) + .help("Personalizes email for variables defined in the message template"), + Arg::with_name(arg::DISPLAY) + .long(arg::DISPLAY) + .takes_value(false) + .help("Print emails to terminal"), + Arg::with_name(arg::DRY_RUN) + .long(arg::DRY_RUN) + .takes_value(false) + .help("Prepare emails but do not send emails"), + Arg::with_name(arg::ASSUME_YES) + .long(arg::ASSUME_YES) + .takes_value(false) + .help("Send emails without confirmation"), + Arg::with_name(arg::SSH_TUNNEL) + .long(arg::SSH_TUNNEL) + .value_name("port") + .takes_value(true) + .help("Query db through ssh tunnel"), + Arg::with_name(arg::CONNECTION) + .long(arg::CONNECTION) + .takes_value(true) + .possible_values(&[val::SMTP, val::AWS]) + .default_value(val::SMTP) + .help("Send emails via SMTP or AWS API"), + Arg::with_name(arg::VERBOSE) + .long(arg::VERBOSE) + .takes_value(false) + .help("Shows what is going on for subcommand"), + ]), ) } diff --git a/src/sources/csv.rs b/src/sources/csv.rs new file mode 100644 index 0000000..99c1576 --- /dev/null +++ b/src/sources/csv.rs @@ -0,0 +1,124 @@ +use anyhow::Context; +use chrono::{DateTime, Utc}; +use polars::prelude::{CsvReader, CsvWriter, DataFrame, SerReader, SerWriter}; +use std::{fs, path::Path}; + +pub fn read_csv(csv_file: &Path) -> Result { + println!("Reading csv file '{}' ...", csv_file.display()); + let reader = CsvReader::from_path(csv_file)?.has_header(true); + let df = reader.finish()?; + Ok(df) +} + +pub fn write_csv( + df: &mut DataFrame, + save_dir: &Path, + now: DateTime, +) -> Result<(), anyhow::Error> { + let current_time = now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + + let target_file = format!("query_{}.csv", ¤t_time); + + match save_dir.exists() { + true => (), + false => fs::create_dir(save_dir) + .context(format!("Can't create directory: '{}'", save_dir.display()))?, + } + + let target_path = save_dir.join(target_file); + println!("Save query result to file: {}", target_path.display()); + + let csv_file = &mut fs::File::create(target_path)?; + let timestamp_format = "%F_H:%M:%S"; + + CsvWriter::new(csv_file) + .with_datetime_format(Some(timestamp_format.to_string())) + .finish(df)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use polars::{datatypes::AnyValue, frame::row::Row, prelude::NamedFrom, series::Series}; + use tempfile::tempdir; + + #[test] + fn test_read_csv() { + let csv_file = Path::new("./test_data/receiver.csv"); + let res = read_csv(csv_file); + assert!(res.is_ok()); + + let df_receiver = res.unwrap(); + assert_eq!( + df_receiver.get_column_names(), + &["first_name", "last_name", "email"] + ); + assert_eq!( + df_receiver.get_row(0).unwrap(), + Row::new( + ["Marie", "Curie", "marie@curie.com"] + .into_iter() + .map(AnyValue::Utf8) + .collect() + ) + ); + assert_eq!( + df_receiver.get_row(1).unwrap(), + Row::new( + ["Alexandre", "Grothendieck", "alexandre@grothendieck.com"] + .into_iter() + .map(AnyValue::Utf8) + .collect() + ) + ); + } + + #[test] + fn test_write_csv() { + let timestamp = chrono::DateTime::parse_from_rfc3339("2024-01-01T14:00:00Z").unwrap(); + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + let first_name_column = Series::new("first_name", &["Marie", "Alexandre"]); + let last_name_column = Series::new("last_name", &["Curie", "Grothendieck"]); + let email_column = Series::new("email", &["marie@curie.com", "alexandre@grothendieck.com"]); + let mut df_receiver = + DataFrame::new(vec![first_name_column, last_name_column, email_column]).unwrap(); + + let res = write_csv(&mut df_receiver, temp_path, timestamp.naive_utc().and_utc()); + assert!(res.is_ok(), "{}", res.unwrap_err()); + + let csv_file = temp_path.join(format!( + "query_{}.csv", + timestamp.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + )); + assert!(csv_file.exists()); + + let df_receiver = read_csv(&csv_file).unwrap(); + assert_eq!( + df_receiver.get_column_names(), + &["first_name", "last_name", "email"] + ); + assert_eq!( + df_receiver.get_row(0).unwrap(), + Row::new( + ["Marie", "Curie", "marie@curie.com"] + .into_iter() + .map(AnyValue::Utf8) + .collect() + ) + ); + assert_eq!( + df_receiver.get_row(1).unwrap(), + Row::new( + ["Alexandre", "Grothendieck", "alexandre@grothendieck.com"] + .into_iter() + .map(AnyValue::Utf8) + .collect() + ) + ); + } +} diff --git a/src/data_sources/image.rs b/src/sources/image.rs similarity index 100% rename from src/data_sources/image.rs rename to src/sources/image.rs diff --git a/src/data_sources/mod.rs b/src/sources/mod.rs similarity index 73% rename from src/data_sources/mod.rs rename to src/sources/mod.rs index 40d86ff..23d6146 100644 --- a/src/data_sources/mod.rs +++ b/src/sources/mod.rs @@ -6,6 +6,6 @@ mod ssh_tunnel; pub use self::{ csv::{read_csv, write_csv}, image::write_image, - postgres::{query_postgres, ConnVars}, + postgres::{query_postgres, ConnVars, DbConnection}, }; pub use ssh_tunnel::SshTunnel; diff --git a/src/data_sources/postgres.rs b/src/sources/postgres.rs similarity index 66% rename from src/data_sources/postgres.rs rename to src/sources/postgres.rs index 4527eae..ad81999 100644 --- a/src/data_sources/postgres.rs +++ b/src/sources/postgres.rs @@ -1,6 +1,5 @@ -use crate::{arg, data_sources::SshTunnel}; +use super::SshTunnel; use anyhow::{Context, Result}; -use clap::ArgMatches; use connectorx::{ destinations::arrow2::Arrow2Destination, prelude::{Dispatcher, PostgresArrow2Transport}, @@ -61,29 +60,44 @@ impl ConnVars { Ok(conn_vars) } - pub fn connection_url(&self) -> String { - format!( + pub fn connection_url(&self) -> Result { + let connection_url = format!( "postgresql://{}:{}@{}:{}/{}", &self.db_user, &self.db_password.0, &self.db_host, &self.db_port, &self.db_name - ) + ); + let url = Url::parse(&connection_url)?; + Ok(url) } } -pub fn query_postgres(matches: &ArgMatches, query: &str) -> Result { - let conn_vars = ConnVars::from_env()?; - - let ssh_tunnel = if matches.is_present(arg::SSH_TUNNEL) { - Some(SshTunnel::new(matches, &conn_vars)?) - } else { - None - }; +pub struct DbConnection { + /// The connection url of the DB. + url: Url, + /// Connection via ssh tunnel. + ssh_tunnel: Option, +} - let connection_url = match &ssh_tunnel { - Some(tunnel) => Url::parse(&tunnel.connection_url)?, - None => Url::parse(&conn_vars.connection_url())?, - }; +impl DbConnection { + pub fn new(conn_vars: &ConnVars, ssh_tunnel: Option<&str>) -> Result { + let connection = if let Some(tunnel) = ssh_tunnel { + let ssh_tunnel = SshTunnel::new(tunnel, conn_vars)?; + Self { + url: ssh_tunnel.connection_url().to_owned(), + ssh_tunnel: Some(ssh_tunnel), + } + } else { + let connection_url = conn_vars.connection_url()?; + Self { + url: connection_url, + ssh_tunnel: None, + } + }; + Ok(connection) + } +} - let (config, _tls) = rewrite_tls_args(&connection_url)?; +pub fn query_postgres(connection: &DbConnection, query: &str) -> Result { + let (config, _tls) = rewrite_tls_args(&connection.url)?; let source = PostgresSource::::new(config, NoTls, 3)?; let mut destination = Arrow2Destination::new(); let queries = &[CXQuery::naked(query)]; @@ -96,9 +110,8 @@ pub fn query_postgres(matches: &ArgMatches, query: &str) -> Result tunnel.kill()?, - None => (), + if let Some(tunnel) = &connection.ssh_tunnel { + tunnel.kill()?; } Ok(df) diff --git a/src/data_sources/ssh_tunnel.rs b/src/sources/ssh_tunnel.rs similarity index 77% rename from src/data_sources/ssh_tunnel.rs rename to src/sources/ssh_tunnel.rs index 4af20f2..ccd4ef0 100644 --- a/src/data_sources/ssh_tunnel.rs +++ b/src/sources/ssh_tunnel.rs @@ -1,20 +1,20 @@ -use crate::{arg, data_sources::postgres::ConnVars}; +use crate::sources::postgres::ConnVars; use anyhow::{anyhow, Context, Result}; -use clap::ArgMatches; use std::{ env, process::{Child, Command}, }; +use url::Url; pub struct SshTunnel { process: Child, - pub connection_url: String, + connection_url: Url, } const LOCALHOST: &str = "127.0.0.1"; impl SshTunnel { - pub fn new(matches: &ArgMatches, conn_vars: &ConnVars) -> Result { + pub fn new(ssh_tunnel: &str, conn_vars: &ConnVars) -> Result { println!("Opening ssh tunnel ..."); let server_host = env::var("SERVER_HOST") @@ -22,10 +22,7 @@ impl SshTunnel { let server_user = env::var("SERVER_USER") .context("Missing environment variable 'SERVER_USER'. Needed for ssh tunnel.")?; - let local_port = match matches.value_of(arg::SSH_TUNNEL) { - Some(port) => port, - None => return Err(anyhow!("Missing value for argument '{}'", arg::SSH_TUNNEL)), - }; + let local_port = ssh_tunnel; let local_url = &(LOCALHOST.to_string() + ":" + local_port) as &str; let db_url = format!("{}:{}", conn_vars.db_host, &conn_vars.db_port); @@ -36,7 +33,12 @@ impl SshTunnel { .args(["-N", "-T", "-L", &port_fwd, &ssh_connection]) .spawn()?; - let connection_url = SshTunnel::connection_url(conn_vars, local_url); + let connection_url = format!( + "postgresql://{}:{}@{}/{}", + &conn_vars.db_user, &conn_vars.db_password.0, local_url, &conn_vars.db_name + ); + let connection_url = Url::parse(&connection_url)?; + let ssh_tunnel = SshTunnel { process, connection_url, @@ -49,6 +51,10 @@ impl SshTunnel { Ok(ssh_tunnel) } + pub fn connection_url(&self) -> &Url { + &self.connection_url + } + pub fn kill(&self) -> Result<(), anyhow::Error> { let pid = self.process.id(); let mut cmd = Command::new("kill"); @@ -76,11 +82,4 @@ impl SshTunnel { ))), } } - - fn connection_url(conn_vars: &ConnVars, tunnel_url: &str) -> String { - format!( - "postgresql://{}:{}@{}/{}", - &conn_vars.db_user, &conn_vars.db_password.0, tunnel_url, &conn_vars.db_name - ) - } } diff --git a/src/helper.rs b/src/utils.rs similarity index 63% rename from src/helper.rs rename to src/utils.rs index 9d96761..9eeae1a 100644 --- a/src/helper.rs +++ b/src/utils.rs @@ -1,3 +1,5 @@ +use std::{fs, path::Path}; + pub fn format_green(text: &str) -> String { const GREEN: &str = "\x1b[32m"; const END: &str = "\x1b[0m"; @@ -11,3 +13,9 @@ pub fn format_red(text: &str) -> String { let red_text = format!("{}{}{}", RED, text, END); red_text } + +pub fn read_file(path: &Path) -> Result { + println!("Reading file '{}' ...", path.display()); + let content = fs::read_to_string(path)?; + Ok(content) +} diff --git a/test_data/email_multipart_alternative.txt b/test_data/email_multipart_alternative.txt new file mode 100644 index 0000000..dd8ece9 --- /dev/null +++ b/test_data/email_multipart_alternative.txt @@ -0,0 +1,19 @@ +From: albert@einstein.com +To: marie@curie.com +Subject: Test Subject +Date: Mon, 01 Jan 2024 14:00:00 +0000 +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt" + +--RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 7bit + +This is a test message (plaintext). +--RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: 7bit + +

This is a test message (html).

+--RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt-- diff --git a/test_data/email_multipart_mixed.txt b/test_data/email_multipart_mixed.txt new file mode 100644 index 0000000..c5faf53 --- /dev/null +++ b/test_data/email_multipart_mixed.txt @@ -0,0 +1,20 @@ +From: albert@einstein.com +To: marie@curie.com +Subject: Test Subject +Date: Mon, 01 Jan 2024 14:00:00 +0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt" + +--RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 7bit + +This is a test message (plaintext). +--RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="test.txt" +Content-Transfer-Encoding: 7bit + +This is a test attachment. +--RZcCpBhV4GEzm8ETTVblOuzZ8bwGzGVyjkQfGTMt-- diff --git a/test_data/email_multipart_mixed_alternative.txt b/test_data/email_multipart_mixed_alternative.txt new file mode 100644 index 0000000..fa0d1cd --- /dev/null +++ b/test_data/email_multipart_mixed_alternative.txt @@ -0,0 +1,30 @@ +From: albert@einstein.com +To: marie@curie.com +Subject: Test Subject +Date: Mon, 01 Jan 2024 14:00:00 +0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="OTi56O3hPypBNfzLsCk053S1timfKY03AexmLpxU" + +--OTi56O3hPypBNfzLsCk053S1timfKY03AexmLpxU +Content-Type: multipart/alternative; + boundary="HDKQ1fKhhPf7wLdMpdLSlteF05Rxv6VCIqIQf82I" + +--HDKQ1fKhhPf7wLdMpdLSlteF05Rxv6VCIqIQf82I +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 7bit + +This is a test message (plaintext). +--HDKQ1fKhhPf7wLdMpdLSlteF05Rxv6VCIqIQf82I +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: 7bit + +

This is a test message (html).

+--HDKQ1fKhhPf7wLdMpdLSlteF05Rxv6VCIqIQf82I-- +--OTi56O3hPypBNfzLsCk053S1timfKY03AexmLpxU +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="test.txt" +Content-Transfer-Encoding: 7bit + +This is a test attachment. +--OTi56O3hPypBNfzLsCk053S1timfKY03AexmLpxU-- diff --git a/test_data/email_singlepart_attachment.txt b/test_data/email_singlepart_attachment.txt new file mode 100644 index 0000000..d287934 --- /dev/null +++ b/test_data/email_singlepart_attachment.txt @@ -0,0 +1,10 @@ +From: albert@einstein.com +To: marie@curie.com +Subject: Test Subject +Date: Mon, 01 Jan 2024 14:00:00 +0000 +MIME-Version: 1.0 +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="test.txt" +Content-Transfer-Encoding: 7bit + +This is a test attachment. diff --git a/test_data/email_singlepart_html.txt b/test_data/email_singlepart_html.txt new file mode 100644 index 0000000..673af82 --- /dev/null +++ b/test_data/email_singlepart_html.txt @@ -0,0 +1,9 @@ +From: albert@einstein.com +To: marie@curie.com +Subject: Test Subject +Date: Mon, 01 Jan 2024 14:00:00 +0000 +MIME-Version: 1.0 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: 7bit + +

This is a test message (html).

diff --git a/test_data/email_singlepart_plaintext.txt b/test_data/email_singlepart_plaintext.txt new file mode 100644 index 0000000..f5fd67e --- /dev/null +++ b/test_data/email_singlepart_plaintext.txt @@ -0,0 +1,9 @@ +From: albert@einstein.com +To: marie@curie.com +Subject: Test Subject +Date: Mon, 01 Jan 2024 14:00:00 +0000 +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 7bit + +This is a test message (plaintext). diff --git a/test_data/empty_message.yaml b/test_data/message_empty.yaml similarity index 100% rename from test_data/empty_message.yaml rename to test_data/message_empty.yaml diff --git a/test_data/content_none_message.yaml b/test_data/message_none.yaml similarity index 100% rename from test_data/content_none_message.yaml rename to test_data/message_none.yaml diff --git a/test_data/message_personalized.yaml b/test_data/message_personalized.yaml index f0a92a1..b3f49ac 100644 --- a/test_data/message_personalized.yaml +++ b/test_data/message_personalized.yaml @@ -5,7 +5,7 @@ subject: "Test subject" # The plaintext version text: "Dear {first_name} {last_name}, - \nThis is a test message (plaintext)." + This is a test message (plaintext)." # The html version html: "Dear {first_name} {last_name},
diff --git a/test_data/none_html_message.yaml b/test_data/none_html_message.yaml deleted file mode 100644 index df81603..0000000 --- a/test_data/none_html_message.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Specify the subject, plaintext and html version of your email. -# Personalize message by wrapping variables in curly brackets, eg. {first_name}. - -# The subject of your email -subject: "Test subject" -# The plaintext version -text: "" diff --git a/test_data/test.txt b/test_data/test.txt new file mode 100644 index 0000000..3eae1d0 --- /dev/null +++ b/test_data/test.txt @@ -0,0 +1 @@ +This is a test attachment. \ No newline at end of file diff --git a/tests/cmd/lib.rs b/tests/cmd/lib.rs new file mode 100644 index 0000000..7bbe22f --- /dev/null +++ b/tests/cmd/lib.rs @@ -0,0 +1,6 @@ +mod test_connect; +mod test_init; +mod test_query; +mod test_read; +mod test_send; +mod test_send_bulk; diff --git a/tests/cmd/test_connect.rs b/tests/cmd/test_connect.rs new file mode 100644 index 0000000..1d3928f --- /dev/null +++ b/tests/cmd/test_connect.rs @@ -0,0 +1,28 @@ +use assert_cmd::Command; +use predicates::{boolean::PredicateBooleanExt, str}; + +/// This test requires environment variables `SMTP_SERVER`, `SMTP_USERNAME`, and +/// `SMTP_PASSWORD`. +#[test] +#[ignore] +fn test_connect_smtp() { + println!("Execute 'pigeon connect smtp'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.args(["connect", "smtp"]); + cmd.assert() + .success() + .stdout(str::contains("Connecting to SMTP server").and(str::contains("ok"))); +} + +/// This test requires environment variables `AWS_ACCESS_KEY_ID`, +/// `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION`. +#[test] +#[ignore] +fn test_connect_aws() { + println!("Execute 'pigeon connect aws'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.args(["connect", "aws"]); + cmd.assert() + .success() + .stdout(str::contains("Connecting to aws server in region").and(str::contains("ok"))); +} diff --git a/tests/cmd/test_init.rs b/tests/cmd/test_init.rs new file mode 100644 index 0000000..e9142b4 --- /dev/null +++ b/tests/cmd/test_init.rs @@ -0,0 +1,20 @@ +use assert_cmd::Command; +use predicates::{boolean::PredicateBooleanExt, str}; +use tempfile::tempdir; + +#[test] +fn test_init() { + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + println!("Execute 'pigeon init'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.current_dir(temp_path); + cmd.args(["init"]); + cmd.assert().success().stdout( + str::contains("Creating message template in current directory:").and(str::contains( + format!("{}/message.yaml", temp_path.display()), + )), + ); +} diff --git a/tests/cmd/test_query.rs b/tests/cmd/test_query.rs new file mode 100644 index 0000000..1f3a345 --- /dev/null +++ b/tests/cmd/test_query.rs @@ -0,0 +1,96 @@ +/* These tests requires the following environment variables: + - DB_HOST + - DB_PORT + - DB_USER + - DB_PASSWORD + - DB_NAME + - TEST_QUERY +*/ + +use assert_cmd::Command; +use predicates::str; +use std::{env, fs}; +use tempfile::tempdir; + +#[test] +#[ignore] +fn test_query_display() { + let test_query = env::var("TEST_QUERY").expect("Missing environment variable 'TEST_QUERY'"); + println!("Execute 'pigeon query {test_query} --display'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.args(["query", test_query.as_str(), "--display"]); + cmd.assert() + .success() + .stdout(str::contains("Display query result")); +} + +#[test] +#[ignore] +fn test_query_save() { + let test_query = env::var("TEST_QUERY").expect("Missing environment variable 'TEST_QUERY'"); + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + let save_dir = temp_path.to_str().unwrap(); + + println!("Execute 'pigeon query {test_query} --save'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.current_dir(save_dir); + cmd.args(["query", test_query.as_str(), "--save"]); + cmd.assert() + .success() + .stdout(str::contains("Save query result to file")); + + if let Ok(mut entries) = fs::read_dir(temp_path.join("saved_queries")) { + let dir_entry = entries.find_map(|entry| { + if let Ok(entry) = entry { + if entry.file_name().to_str().is_some_and(|file_name| { + file_name.contains("query") && file_name.ends_with(".csv") + }) { + return Some(entry); + } + } + + None + }); + assert!(dir_entry.is_some()); + } +} + +#[test] +#[ignore] +fn test_query_save_dir() { + let test_query = env::var("TEST_QUERY").expect("Missing environment variable 'TEST_QUERY'"); + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + let save_dir = temp_path.to_str().unwrap(); + + println!("Execute 'pigeon query {test_query} --save --save-dir {save_dir}'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.args([ + "query", + test_query.as_str(), + "--save", + "--save-dir", + save_dir, + ]); + cmd.assert() + .success() + .stdout(str::contains("Save query result to file")); + + if let Ok(mut entries) = fs::read_dir(temp_path) { + let dir_entry = entries.find_map(|entry| { + if let Ok(entry) = entry { + if entry.file_name().to_str().is_some_and(|file_name| { + file_name.contains("query") && file_name.ends_with(".csv") + }) { + return Some(entry); + } + } + + None + }); + assert!(dir_entry.is_some()); + } +} diff --git a/tests/cmd/test_read.rs b/tests/cmd/test_read.rs new file mode 100644 index 0000000..f2656ce --- /dev/null +++ b/tests/cmd/test_read.rs @@ -0,0 +1,34 @@ +use assert_cmd::Command; +use predicates::{boolean::PredicateBooleanExt, str}; + +#[test] +fn test_read() { + let test_data = "./test_data/receiver.csv"; + println!("Execute 'pigeon read {test_data}'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.args(["read", test_data]); + cmd.assert().success().stdout(str::contains( + "Reading csv file './test_data/receiver.csv' ...", + )); +} + +#[test] +fn test_read_display() { + let test_data = "./test_data/receiver.csv"; + println!("Execute 'pigeon read {test_data} --display'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.args(["read", test_data, "--display"]); + cmd.assert().success().stdout( + str::contains("Reading csv file './test_data/receiver.csv' ...").and(str::contains( + "Display csv file: shape: (2, 3) +┌────────────┬──────────────┬────────────────────────────┐ +│ first_name ┆ last_name ┆ email │ +│ --- ┆ --- ┆ --- │ +│ str ┆ str ┆ str │ +╞════════════╪══════════════╪════════════════════════════╡ +│ Marie ┆ Curie ┆ marie@curie.com │ +│ Alexandre ┆ Grothendieck ┆ alexandre@grothendieck.com │ +└────────────┴──────────────┴────────────────────────────┘", + )), + ); +} diff --git a/tests/cmd/test_send.rs b/tests/cmd/test_send.rs new file mode 100644 index 0000000..c55e219 --- /dev/null +++ b/tests/cmd/test_send.rs @@ -0,0 +1,186 @@ +use assert_cmd::Command; +use predicates::{boolean::PredicateBooleanExt, str}; +use std::{env, fs}; +use tempfile::tempdir; + +/// This test requires the following environment variables: +/// - SMTP_SERVER +/// - SMTP_USERNAME +/// - SMTP_PASSWORD +/// - TEST_SENDER +/// - TEST_RECEIVER +#[test] +#[ignore] +fn test_send_smtp() { + let sender = env::var("TEST_SENDER").expect("Missing environment variable 'TEST_SENDER'"); + let receiver = env::var("TEST_RECEIVER").expect("Missing environment variable 'TEST_RECEIVER'"); + + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + fs::copy("./test_data/message.yaml", temp_path.join("message.yaml")).unwrap(); + fs::copy("./test_data/test.pdf", temp_path.join("test.pdf")).unwrap(); + + println!("Execute 'pigeon send --connection smtp'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.current_dir(temp_path); + cmd.args([ + "send", + &sender, + &receiver, + "--message-file", + "./message.yaml", + "--attachment", + "./test.pdf", + "--archive", + "--archive-dir", + "./my-sent-emails", + "--display", + "--assume-yes", + "--connection", + "smtp", + ]); + cmd.assert().success().stdout( + str::contains("Reading csv file './receiver.csv' ...") + .and(str::contains("Display csv file:").and(str::contains("Display emails:"))), + ); + + assert!(temp_path.join("my-sent-emails").exists()); +} + +/// This test requires the following environment variables: +/// - AWS_ACCESS_KEY_ID +/// - AWS_SECRET_ACCESS_KEY +/// - AWS_REGION +/// - TEST_SENDER +/// - TEST_RECEIVER +#[test] +#[ignore] +fn test_send_aws() { + let sender = env::var("TEST_SENDER").expect("Missing environment variable 'TEST_SENDER'"); + let receiver = env::var("TEST_RECEIVER").expect("Missing environment variable 'TEST_RECEIVER'"); + + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + fs::copy("./test_data/message.yaml", temp_path.join("message.yaml")).unwrap(); + fs::copy("./test_data/test.pdf", temp_path.join("test.pdf")).unwrap(); + + println!("Execute 'pigeon send --connection aws ...'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.current_dir(temp_path); + cmd.args([ + "send", + &sender, + &receiver, + "--message-file", + "./message.yaml", + "--attachment", + "./test.pdf", + "--archive", + "--archive-dir", + "./my-sent-emails", + "--display", + "--assume-yes", + "--connection", + "aws", + ]); + cmd.assert().success().stdout( + str::contains("Reading csv file './receiver.csv' ...") + .and(str::contains("Display csv file:").and(str::contains("Display email:"))), + ); + + assert!(temp_path.join("my-sent-emails").exists()); +} + +#[test] +fn test_send_smtp_dry() { + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + fs::copy("./test_data/message.yaml", temp_path.join("message.yaml")).unwrap(); + fs::copy("./test_data/test.pdf", temp_path.join("test.pdf")).unwrap(); + + println!("Execute 'pigeon send --connection smtp'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.current_dir(temp_path); + cmd.args([ + "send", + "albert@einstein.com", + "marie@curie.com", + "--message-file", + "./message.yaml", + "--attachment", + "./test.pdf", + "--archive", + "--archive-dir", + "./my-sent-emails", + "--display", + "--assume-yes", + "--connection", + "smtp", + "--dry-run", + ]); + cmd.assert().success().stdout( + str::contains("Reading message file './message.yaml' ...") + .and(str::contains("Display message file:")) + .and(str::contains("Display email:")) + .and(str::contains("Dry run: \u{1b}[32mactivated\u{1b}[0m")) + .and(str::contains("Sending email to 1 receiver ...")) + .and(str::contains( + "marie@curie.com ... \u{1b}[32mdry run\u{1b}[0m", + )) + .and(str::contains("Archiving './my-sent-emails")) + .and(str::contains("Email sent (dry run)")), + ); + + assert!(temp_path.join("my-sent-emails").exists()); +} + +#[test] +fn test_send_aws_dry() { + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + fs::copy("./test_data/message.yaml", temp_path.join("message.yaml")).unwrap(); + fs::copy("./test_data/test.pdf", temp_path.join("test.pdf")).unwrap(); + + println!("Execute 'pigeon send --connection aws ...'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.current_dir(temp_path); + cmd.args([ + "send", + "albert@einstein.com", + "marie@curie.com", + "--message-file", + "./message.yaml", + "--attachment", + "./test.pdf", + "--archive", + "--archive-dir", + "./my-sent-emails", + "--display", + "--assume-yes", + "--connection", + "aws", + "--dry-run", + ]); + cmd.assert().success().stdout( + str::contains("Reading message file './message.yaml' ...") + .and(str::contains("Display message file:")) + .and(str::contains("Display email:")) + .and(str::contains("Dry run: \u{1b}[32mactivated\u{1b}[0m")) + .and(str::contains("Sending email to 1 receiver ...")) + .and(str::contains( + "marie@curie.com ... \u{1b}[32mdry run\u{1b}[0m", + )) + .and(str::contains("Archiving './my-sent-emails")) + .and(str::contains("Email sent (dry run)")), + ); + + assert!(temp_path.join("my-sent-emails").exists()); +} diff --git a/tests/cmd/test_send_bulk.rs b/tests/cmd/test_send_bulk.rs new file mode 100644 index 0000000..0c5a7c8 --- /dev/null +++ b/tests/cmd/test_send_bulk.rs @@ -0,0 +1,100 @@ +use assert_cmd::Command; +use predicates::{boolean::PredicateBooleanExt, str}; +use std::fs; +use tempfile::tempdir; + +#[test] +fn test_send_bulk_smtp_dry() { + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + fs::copy("./test_data/receiver.csv", temp_path.join("receiver.csv")).unwrap(); + fs::copy("./test_data/message.yaml", temp_path.join("message.yaml")).unwrap(); + fs::copy("./test_data/test.pdf", temp_path.join("test.pdf")).unwrap(); + + println!("Execute 'pigeon send-bulk'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.current_dir(temp_path); + cmd.args([ + "send-bulk", + "albert@einstein.com", + "--receiver-file", + "./receiver.csv", + "--message-file", + "./message.yaml", + "--attachment", + "./test.pdf", + "--archive", + "--archive-dir", + "./my-sent-emails", + "--display", + "--assume-yes", + "--dry-run", + ]); + cmd.assert().success().stdout( + str::contains("Reading csv file './receiver.csv' ...") + .and(str::contains("Display csv file:")) + .and(str::contains("Reading message file './message.yaml' ...")) + .and(str::contains("Display message file:")) + .and(str::contains("Display emails:")) + .and(str::contains("Dry run: \u{1b}[32mactivated\u{1b}[0m")) + .and(str::contains("Sending email to 2 receivers ...")) + .and(str::contains( + "marie@curie.com ... \u{1b}[32mdry run\u{1b}[0m", + )) + .and(str::contains("Archiving './my-sent-emails")) + .and(str::contains("All emails sent (dry run)")), + ); + + assert!(temp_path.join("my-sent-emails").exists()); +} + +#[test] +fn test_send_bulk_aws_dry() { + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + assert!(temp_path.exists(), "Missing path: {}", temp_path.display()); + + fs::copy("./test_data/receiver.csv", temp_path.join("receiver.csv")).unwrap(); + fs::copy("./test_data/message.yaml", temp_path.join("message.yaml")).unwrap(); + fs::copy("./test_data/test.pdf", temp_path.join("test.pdf")).unwrap(); + + println!("Execute 'pigeon send-bulk'"); + let mut cmd = Command::cargo_bin("pigeon").unwrap(); + cmd.current_dir(temp_path); + cmd.args([ + "send-bulk", + "albert@einstein.com", + "--receiver-file", + "./receiver.csv", + "--message-file", + "./message.yaml", + "--attachment", + "./test.pdf", + "--archive", + "--archive-dir", + "./my-sent-emails", + "--display", + "--assume-yes", + "--connection", + "aws", + "--dry-run", + ]); + cmd.assert().success().stdout( + str::contains("Reading csv file './receiver.csv' ...") + .and(str::contains("Display csv file:")) + .and(str::contains("Reading message file './message.yaml' ...")) + .and(str::contains("Display message file:")) + .and(str::contains("Display emails:")) + .and(str::contains("Dry run: \u{1b}[32mactivated\u{1b}[0m")) + .and(str::contains("Sending email to 2 receivers ...")) + .and(str::contains( + "marie@curie.com ... \u{1b}[32mdry run\u{1b}[0m", + )) + .and(str::contains("Archiving './my-sent-emails")) + .and(str::contains("All emails sent (dry run)")), + ); + + assert!(temp_path.join("my-sent-emails").exists()); +}