From f5e5a09a5e7be2e32ad725ba81efc8ef41fc14a8 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Sun, 14 Apr 2024 10:15:22 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Email=20to=20device=20(#296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :construction: Start `email` crate and notifier adjustments Support for configuring an email client and encrypting secrets associated with a notifier (if necessary) * encryption key in DB 😬 * WIP API for emailer * Worked on the plane 💅 * WIP save whatever I was working on * fix merge issues * WIP: create emailer form * WIP: create emailer form, almost * WIP: emailer card * Email send history * WIP actually sending emails * Validate and tx the recipients * WIP devices * WIP email works! * UI work for send history and create/update device * WIP: flesh out emailer UI pages * Add modal for emailing book * Support TLS in email * Add migration * Write docs for emailer crate * fix clippy lints --- .prettierignore | 3 +- .vscode/settings.json | 2 +- Cargo.lock | 335 ++++++- Cargo.toml | 9 + apps/server/src/filter/basic_filter.rs | 12 + apps/server/src/http_server.rs | 5 + apps/server/src/main.rs | 4 + apps/server/src/routers/api/mod.rs | 27 +- apps/server/src/routers/api/v1/emailer.rs | 868 ++++++++++++++++++ apps/server/src/routers/api/v1/media.rs | 2 +- apps/server/src/routers/api/v1/mod.rs | 2 + apps/server/src/routers/api/v1/notifier.rs | 26 +- core/Cargo.toml | 57 +- .../migration.sql | 45 + core/prisma/schema.prisma | 63 +- core/src/config/stump_config.rs | 21 +- core/src/context.rs | 19 +- core/src/db/entity/emailer/device.rs | 24 + core/src/db/entity/emailer/entity.rs | 128 +++ core/src/db/entity/emailer/history.rs | 83 ++ core/src/db/entity/emailer/mod.rs | 7 + core/src/db/entity/mod.rs | 2 + core/src/db/entity/notifier.rs | 59 +- core/src/db/entity/user/entity.rs | 17 +- core/src/db/entity/user/permissions.rs | 68 +- core/src/error.rs | 10 +- core/src/lib.rs | 56 +- core/src/utils.rs | 28 + crates/email/Cargo.toml | 16 + crates/email/src/emailer.rs | 250 +++++ crates/email/src/error.rs | 21 + crates/email/src/lib.rs | 15 + crates/email/src/template.rs | 74 ++ crates/email/templates/attachment.hbs | 7 + crates/email/templates/base.hbs | 7 + crates/integrations/Cargo.toml | 1 + packages/api/src/emailer.ts | 122 +++ packages/api/src/index.ts | 1 + .../src/components/GenericEmptyState.tsx | 5 +- .../src/components/table/Pagination.tsx | 6 +- .../browser/src/components/table/SortIcon.tsx | 8 +- .../browser/src/components/table/Table.tsx | 22 +- .../browser/src/components/table/index.ts | 2 +- packages/browser/src/i18n/locales/fr.json | 724 --------------- packages/browser/src/paths.ts | 4 + .../src/scenes/book/BookOverviewScene.tsx | 2 + .../src/scenes/book/EmailBookDropdown.tsx | 169 ++++ .../src/scenes/settings/SettingsHeader.tsx | 24 +- .../browser/src/scenes/settings/routes.ts | 44 +- .../settings/server/ServerSettingsRouter.tsx | 5 +- .../server/email/CreateEmailerScene.tsx | 61 ++ .../server/email/EditEmailerScene.tsx | 70 ++ .../server/email/EmailSettingsRouter.tsx | 42 + .../server/email/EmailSettingsScene.tsx | 27 + .../scenes/settings/server/email/context.ts | 13 + .../devices/CreateOrUpdateDeviceModal.tsx | 115 +++ .../devices/DeleteDeviceConfirmation.tsx | 46 + .../server/email/devices/DeviceActionMenu.tsx | 39 + .../server/email/devices/DevicesSection.tsx | 57 ++ .../server/email/devices/DevicesTable.tsx | 119 +++ .../settings/server/email/devices/index.ts | 1 + .../emailers/CreateOrUpdateEmailerForm.tsx | 259 ++++++ .../email/emailers/EmailerActionMenu.tsx | 39 + .../server/email/emailers/EmailerListItem.tsx | 87 ++ .../email/emailers/EmailerSendHistory.tsx | 78 ++ .../emailers/EmailerSendHistoryTable.tsx | 173 ++++ .../EmailerSendRecordAttachmentTable.tsx | 126 +++ .../server/email/emailers/EmailersList.tsx | 44 + .../server/email/emailers/EmailersSection.tsx | 32 + .../settings/server/email/emailers/index.ts | 2 + .../settings/server/email/emailers/utils.ts | 15 + .../src/scenes/settings/server/email/index.ts | 1 + .../general/GeneralServerSettingsScene.tsx | 2 + .../server/general/ServerPublicURL.tsx | 23 + packages/client/src/client.ts | 1 + packages/client/src/queries/emailers.ts | 202 ++++ packages/client/src/queries/index.ts | 12 + .../src/dialog/ConfirmationModal.tsx | 31 +- packages/components/src/drawer/Drawer.tsx | 7 +- packages/components/src/index.ts | 1 + packages/components/src/input/CheckBox.tsx | 2 + .../components/src/input/PasswordInput.tsx | 33 + packages/components/src/input/index.ts | 1 + packages/i18n/src/locales/en.json | 161 +++- packages/i18n/src/locales/fr.json | 157 ++-- packages/types/generated.ts | 56 +- 86 files changed, 4725 insertions(+), 921 deletions(-) create mode 100644 apps/server/src/routers/api/v1/emailer.rs create mode 100644 core/prisma/migrations/20240412235240_emailer_and_encryption/migration.sql create mode 100644 core/src/db/entity/emailer/device.rs create mode 100644 core/src/db/entity/emailer/entity.rs create mode 100644 core/src/db/entity/emailer/history.rs create mode 100644 core/src/db/entity/emailer/mod.rs create mode 100644 crates/email/Cargo.toml create mode 100644 crates/email/src/emailer.rs create mode 100644 crates/email/src/error.rs create mode 100644 crates/email/src/lib.rs create mode 100644 crates/email/src/template.rs create mode 100644 crates/email/templates/attachment.hbs create mode 100644 crates/email/templates/base.hbs create mode 100644 packages/api/src/emailer.ts delete mode 100644 packages/browser/src/i18n/locales/fr.json create mode 100644 packages/browser/src/scenes/book/EmailBookDropdown.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/CreateEmailerScene.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/EditEmailerScene.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/EmailSettingsRouter.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/EmailSettingsScene.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/context.ts create mode 100644 packages/browser/src/scenes/settings/server/email/devices/CreateOrUpdateDeviceModal.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/devices/DeleteDeviceConfirmation.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/devices/DeviceActionMenu.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/devices/DevicesSection.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/devices/DevicesTable.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/devices/index.ts create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/CreateOrUpdateEmailerForm.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/EmailerActionMenu.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/EmailerListItem.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistory.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistoryTable.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/EmailerSendRecordAttachmentTable.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/EmailersList.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/EmailersSection.tsx create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/index.ts create mode 100644 packages/browser/src/scenes/settings/server/email/emailers/utils.ts create mode 100644 packages/browser/src/scenes/settings/server/email/index.ts create mode 100644 packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx create mode 100644 packages/client/src/queries/emailers.ts create mode 100644 packages/components/src/input/PasswordInput.tsx diff --git a/.prettierignore b/.prettierignore index 523296fb0..8d45e60ff 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,7 +7,8 @@ dist build .next .expo +*.hbs -packages/browser/src/i18n/locales/*.json +packages/i18n/src/locales/*.json CHANGELOG.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 396c09d41..2f7463492 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,6 @@ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] ], - "tailwindCSS.classAttributes": ["class", "className", ".*CLASSES", ".*VARIANTS"], + "tailwindCSS.classAttributes": ["class", "className", ".*ClassName", ".*CLASSES", ".*VARIANTS"], "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/Cargo.lock b/Cargo.lock index 8f2d3b954..d26fbca04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.3" @@ -40,6 +50,21 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm-siv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", + "zeroize", +] + [[package]] name = "ahash" version = "0.7.6" @@ -96,6 +121,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "alphanumeric-sort" version = "1.5.3" @@ -177,6 +208,18 @@ version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" +[[package]] +name = "arrayref" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" + +[[package]] +name = "arrayvec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + [[package]] name = "ascii" version = "0.9.3" @@ -462,6 +505,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.68.1" @@ -503,6 +555,17 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" +[[package]] +name = "blake2b_simd" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23285ad32269793932e830392f2fe2f83e26488fd3ec778883a93c8323735780" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq 0.3.0", +] + [[package]] name = "block" version = "0.1.6" @@ -771,6 +834,16 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.3", + "stacker", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -1023,6 +1096,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "constant_time_eq" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" + [[package]] name = "convert_case" version = "0.4.0" @@ -1227,6 +1306,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1288,6 +1368,15 @@ dependencies = [ "syn 1.0.107", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "cty" version = "0.2.2" @@ -1705,6 +1794,36 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" +[[package]] +name = "email" +version = "0.0.1" +dependencies = [ + "handlebars", + "lettre", + "serde", + "serde_json", + "specta", + "thiserror", + "tracing", + "utoipa", +] + +[[package]] +name = "email-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75" +dependencies = [ + "base64 0.21.5", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" + [[package]] name = "embed_plist" version = "1.2.2" @@ -2440,6 +2559,20 @@ dependencies = [ "crunchy", ] +[[package]] +name = "handlebars" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab283476b99e66691dee3f1640fea91487a8d81f50fb5ecc75538f8f8879a1e4" +dependencies = [ + "log", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "hashbrown" version = "0.11.2" @@ -2469,9 +2602,13 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dfda62a12f55daeae5015f81b0baea145391cb4520f86c248fc615d72640d12" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash 0.8.6", + "allocator-api2", +] [[package]] name = "hashlink" @@ -2664,9 +2801,9 @@ dependencies = [ "futures-util", "http", "hyper", - "rustls", + "rustls 0.21.7", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", ] [[package]] @@ -2806,7 +2943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" dependencies = [ "equivalent", - "hashbrown 0.14.1", + "hashbrown 0.14.3", "serde", ] @@ -2892,6 +3029,7 @@ version = "0.0.1" dependencies = [ "async-trait", "dotenv", + "lettre", "reqwest", "serde_json", "thiserror", @@ -3159,6 +3297,37 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" +[[package]] +name = "lettre" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357ff5edb6d8326473a64c82cf41ddf78ab116f89668c50c4fac1b321e5e80f4" +dependencies = [ + "async-trait", + "base64 0.21.5", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls 0.22.2", + "rustls-pemfile 2.1.0", + "socket2 0.5.5", + "tokio", + "tokio-rustls 0.25.0", + "tracing", + "url", + "webpki-roots 0.26.1", +] + [[package]] name = "libc" version = "0.2.152" @@ -4586,6 +4755,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.4.3" @@ -4840,6 +5021,15 @@ dependencies = [ "url", ] +[[package]] +name = "psm" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5787f7cda34e3033a72192c018bc5883100330f362ef279a8cbccfce8bb4e874" +dependencies = [ + "cc", +] + [[package]] name = "qoi" version = "0.4.1" @@ -4992,6 +5182,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ec282e887b434b68c18fe5c121d38e72a5cf35119b59e54ec5b992ea9c8eb0" + [[package]] name = "radix_trie" version = "0.2.1" @@ -5287,21 +5483,21 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", - "rustls-pemfile", + "rustls 0.21.7", + "rustls-pemfile 1.0.3", "serde", "serde_json", "serde_urlencoded", "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 0.25.3", "winreg", ] @@ -5380,6 +5576,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-argon2" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5885493fdf0be6cdff808d1533ce878d21cfa49c7086fa00c66355cd9141bfc" +dependencies = [ + "base64 0.21.5", + "blake2b_simd", + "constant_time_eq 0.3.0", + "crossbeam-utils", +] + [[package]] name = "rust-embed" version = "6.8.1" @@ -5466,10 +5674,24 @@ checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" dependencies = [ "log", "ring 0.16.20", - "rustls-webpki", + "rustls-webpki 0.101.6", "sct", ] +[[package]] +name = "rustls" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e87c9956bd9807afa1f77e0f7594af32566e830e088a5576d27c5b6f30f49d41" +dependencies = [ + "log", + "ring 0.17.7", + "rustls-pki-types", + "rustls-webpki 0.102.2", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.3" @@ -5479,6 +5701,22 @@ dependencies = [ "base64 0.21.5", ] +[[package]] +name = "rustls-pemfile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c333bb734fcdedcea57de1602543590f545f127dc8b533324318fd492c5c70b" +dependencies = [ + "base64 0.21.5", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ede67b28608b4c60685c7d54122d4400d90f62b40caee7700e700380a390fa8" + [[package]] name = "rustls-webpki" version = "0.101.6" @@ -5489,6 +5727,17 @@ dependencies = [ "untrusted 0.7.1", ] +[[package]] +name = "rustls-webpki" +version = "0.102.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faaa0a62740bedb9b2ef5afa303da42764c012f743917351dc9a237ea1663610" +dependencies = [ + "ring 0.17.7", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.11" @@ -5947,6 +6196,22 @@ dependencies = [ "time", ] +[[package]] +name = "simple_crypt" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a335d088ffc07695a1aee7b94b72a70ba438bed139cf3f3397fcc6c102d113" +dependencies = [ + "aes-gcm-siv", + "anyhow", + "bincode", + "log", + "rust-argon2", + "serde", + "serde_derive", + "tar", +] + [[package]] name = "siphasher" version = "0.3.10" @@ -6214,6 +6479,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c886bd4480155fd3ef527d45e9ac8dd7118a898a46530b7b94c3e21866259fce" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "winapi", +] + [[package]] name = "state" version = "0.5.3" @@ -6299,6 +6577,7 @@ dependencies = [ "cuid", "data-encoding", "dirs 5.0.1", + "email", "epub", "futures", "globset", @@ -6309,12 +6588,14 @@ dependencies = [ "pdf", "pdfium-render", "prisma-client-rust", + "rand 0.8.5", "rayon", "regex", "ring 0.17.7", "serde", "serde-xml-rs", "serde_json", + "simple_crypt", "specta", "tempfile", "thiserror", @@ -6937,7 +7218,18 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.7", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.2", + "rustls-pki-types", "tokio", ] @@ -7359,6 +7651,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unrar" version = "0.5.2" @@ -7748,6 +8050,15 @@ version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" +[[package]] +name = "webpki-roots" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.19.1" @@ -8318,7 +8629,7 @@ dependencies = [ "aes", "byteorder", "bzip2", - "constant_time_eq", + "constant_time_eq 0.1.5", "crc32fast", "crossbeam-utils", "flate2", diff --git a/Cargo.toml b/Cargo.toml index c348ac104..50806a1a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,13 @@ async-stream = "0.3.5" bcrypt = "0.15.0" futures = "0.3.30" futures-util = "0.3.30" +lettre = { version = "0.11.4", default-features = false, features = [ + "builder", + "hostname", + "smtp-transport", + "tracing", + "tokio1-rustls-tls", +] } prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust.git", tag = "0.6.11", features = [ "sqlite-create-many", "migrations", @@ -29,9 +36,11 @@ prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client- "sqlite", "mocking" ], default-features = false } +rand = "0.8.5" reqwest = { version = "0.11.22", default-features = false, features = [ "json", "rustls-tls" ] } serde = { version = "1.0.193", features = ["derive"] } serde_json = "1.0.108" +simple_crypt = "0.2.3" specta = "1.0.5" tempfile = "3.8.1" thiserror = "1.0.51" diff --git a/apps/server/src/filter/basic_filter.rs b/apps/server/src/filter/basic_filter.rs index 1dbd15615..df15ad43a 100644 --- a/apps/server/src/filter/basic_filter.rs +++ b/apps/server/src/filter/basic_filter.rs @@ -318,6 +318,18 @@ pub struct MediaFilter { pub relation_filter: MediaRelationFilter, } +impl MediaFilter { + pub fn ids(ids: Vec) -> Self { + Self { + base_filter: MediaBaseFilter { + id: ids, + ..Default::default() + }, + ..Default::default() + } + } +} + #[derive(Default, Debug, Clone, Deserialize, Serialize, ToSchema)] pub struct LogFilter { pub level: Option, diff --git a/apps/server/src/http_server.rs b/apps/server/src/http_server.rs index 4e1289685..7dad9fe9b 100644 --- a/apps/server/src/http_server.rs +++ b/apps/server/src/http_server.rs @@ -35,6 +35,11 @@ pub async fn run_http_server(config: StumpConfig) -> ServerResult<()> { .await .map_err(|e| ServerError::ServerStartError(e.to_string()))?; + // Initialize the encryption key, if it doesn't exist + core.init_encryption() + .await + .map_err(|e| ServerError::ServerStartError(e.to_string()))?; + core.init_journal_mode() .await .map_err(|e| ServerError::ServerStartError(e.to_string()))?; diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 4d4a88df1..2769a36e5 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -18,6 +18,10 @@ fn debug_setup() { "STUMP_CLIENT_DIR", env!("CARGO_MANIFEST_DIR").to_string() + "/../web/dist", ); + std::env::set_var( + "EMAIL_TEMPLATES_DIR", + env!("CARGO_MANIFEST_DIR").to_string() + "/../../crates/email/templates", + ); std::env::set_var("STUMP_PROFILE", "debug"); } diff --git a/apps/server/src/routers/api/mod.rs b/apps/server/src/routers/api/mod.rs index f4d97ac3d..c476f78d8 100644 --- a/apps/server/src/routers/api/mod.rs +++ b/apps/server/src/routers/api/mod.rs @@ -18,8 +18,9 @@ mod tests { }; use super::v1::{ - auth::*, book_club::*, epub::*, job::*, library::*, media::*, metadata::*, - series::*, smart_list::*, user::*, ClaimResponse, StumpVersion, UpdateCheck, + auth::*, book_club::*, emailer::*, epub::*, job::*, library::*, media::*, + metadata::*, series::*, smart_list::*, user::*, ClaimResponse, StumpVersion, + UpdateCheck, }; #[allow(dead_code)] @@ -54,6 +55,7 @@ mod tests { file.write_all(b"// SERVER TYPE GENERATION\n\n")?; + file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all( @@ -65,7 +67,26 @@ mod tests { format!("{}\n\n", ts_export::()?).as_bytes(), )?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; - file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; + + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; diff --git a/apps/server/src/routers/api/v1/emailer.rs b/apps/server/src/routers/api/v1/emailer.rs new file mode 100644 index 000000000..e3eab8164 --- /dev/null +++ b/apps/server/src/routers/api/v1/emailer.rs @@ -0,0 +1,868 @@ +use std::path::PathBuf; + +use axum::{ + extract::{Path, State}, + middleware::from_extractor_with_state, + routing::{get, post}, + Json, Router, +}; +use prisma_client_rust::{chrono::Utc, Direction}; +use serde::{Deserialize, Serialize}; +use serde_qs::axum::QsQuery; +use specta::Type; +use stump_core::{ + db::entity::{ + AttachmentMeta, EmailerConfig, EmailerConfigInput, EmailerSendRecord, + EmailerSendTo, Media, RegisteredEmailDevice, SMTPEmailer, User, UserPermission, + }, + filesystem::{read_entire_file, ContentType, FileParts, PathUtils}, + prisma::{emailer, emailer_send_record, registered_email_device, user, PrismaClient}, + AttachmentPayload, EmailContentType, +}; +use tower_sessions::Session; +use utoipa::ToSchema; + +use crate::{ + config::state::AppState, + errors::{APIError, APIResult}, + filter::{chain_optional_iter, MediaFilter}, + middleware::auth::Auth, + routers::api::v1::media::apply_media_filters_for_user, + utils::enforce_session_permissions, +}; + +pub(crate) fn mount(app_state: AppState) -> Router { + Router::new() + .nest( + "/emailers", + Router::new() + .route("/", get(get_emailers).post(create_emailer)) + .nest( + "/:id", + Router::new() + .route( + "/", + get(get_emailer_by_id) + .put(update_emailer) + // .patch(patch_emailer) + .delete(delete_emailer), + ) + .nest( + "/send-history", + Router::new().route("/", get(get_emailer_send_history)), + ), + ) + .route("/send-attachment", post(send_attachment_email)), + ) + .nest( + "/email-devices", + Router::new() + .route("/", get(get_email_devices).post(create_email_device)) + .nest( + "/:id", + Router::new().route( + "/", + get(get_email_device_by_id) + .put(update_email_device) + .patch(patch_email_device) + .delete(delete_email_device), + ), + ), + ) + .layer(from_extractor_with_state::(app_state)) +} + +#[derive(Deserialize, ToSchema, Type)] +pub struct EmailerIncludeParams { + #[serde(default)] + pub include_send_history: bool, +} + +#[utoipa::path( + get, + path = "/api/v1/emailers", + tag = "emailer", + responses( + (status = 200, description = "Successfully retrieved emailers", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Bad request"), + (status = 500, description = "Internal server error") + ) +)] +async fn get_emailers( + State(ctx): State, + QsQuery(include_params): QsQuery, + session: Session, +) -> APIResult>> { + enforce_session_permissions(&session, &[UserPermission::EmailerRead])?; + + let client = &ctx.db; + + let mut query = client.emailer().find_many(vec![]); + + // TODO: consider auto truncating? + if include_params.include_send_history { + query = query.with(emailer::send_history::fetch(vec![])) + } + + let emailers = query + .exec() + .await? + .into_iter() + .map(SMTPEmailer::try_from) + .collect::>>(); + let emailers = emailers.into_iter().collect::, _>>()?; + + Ok(Json(emailers)) +} + +#[utoipa::path( + get, + path = "/api/v1/emailers/:id", + tag = "emailer", + params( + ("id" = i32, Path, description = "The emailer ID") + ), + responses( + (status = 200, description = "Successfully retrieved emailer", body = Notifier), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Notifier not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn get_emailer_by_id( + State(ctx): State, + Path(id): Path, + session: Session, +) -> APIResult> { + enforce_session_permissions(&session, &[UserPermission::EmailerRead])?; + + let client = &ctx.db; + + let emailer = client + .emailer() + .find_first(vec![emailer::id::equals(id)]) + .exec() + .await? + .ok_or(APIError::NotFound("Emailer not found".to_string()))?; + + Ok(Json(SMTPEmailer::try_from(emailer)?)) +} + +/// Input object for creating or updating an emailer +#[derive(Deserialize, ToSchema, Type)] +pub struct CreateOrUpdateEmailer { + /// The friendly name for the emailer + name: String, + /// Whether the emailer is the primary emailer + is_primary: bool, + /// The emailer configuration + config: EmailerConfigInput, +} + +/// Create a new emailer +#[utoipa::path( + post, + path = "/api/v1/emailers", + tag = "emailer", + request_body = CreateOrUpdateEmailer, + responses( + (status = 200, description = "Successfully created emailer"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error") + ) +)] +async fn create_emailer( + State(ctx): State, + session: Session, + Json(payload): Json, +) -> APIResult> { + enforce_session_permissions(&session, &[UserPermission::EmailerCreate])?; + + let client = &ctx.db; + + let config = EmailerConfig::from_client_config(payload.config, &ctx).await?; + let emailer = client + .emailer() + .create( + payload.name, + config.sender_email, + config.sender_display_name, + config.username, + config.encrypted_password, + config.smtp_host.to_string(), + config.smtp_port.into(), + vec![ + emailer::is_primary::set(payload.is_primary), + emailer::max_attachment_size_bytes::set(config.max_attachment_size_bytes), + ], + ) + .exec() + .await?; + Ok(Json(SMTPEmailer::try_from(emailer)?)) +} + +/// Update an existing emailer by ID +#[utoipa::path( + put, + path = "/api/v1/emailers/:id", + tag = "emailer", + request_body = CreateOrUpdateEmailer, + params( + ("id" = i32, Path, description = "The id of the emailer to update") + ), + responses( + (status = 200, description = "Successfully updated emailer"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn update_emailer( + State(ctx): State, + Path(id): Path, + session: Session, + Json(payload): Json, +) -> APIResult> { + enforce_session_permissions(&session, &[UserPermission::EmailerManage])?; + + let client = &ctx.db; + let config = EmailerConfig::from_client_config(payload.config, &ctx).await?; + let updated_emailer = client + .emailer() + .update( + emailer::id::equals(id), + vec![ + emailer::name::set(payload.name), + emailer::sender_email::set(config.sender_email), + emailer::sender_display_name::set(config.sender_display_name), + emailer::username::set(config.username), + emailer::encrypted_password::set(config.encrypted_password), + emailer::smtp_host::set(config.smtp_host.to_string()), + emailer::smtp_port::set(config.smtp_port.into()), + emailer::max_attachment_size_bytes::set(config.max_attachment_size_bytes), + ], + ) + .exec() + .await?; + Ok(Json(SMTPEmailer::try_from(updated_emailer)?)) +} + +// #[derive(Deserialize, ToSchema, Type)] +// pub struct PatchEmailer {} + +// #[utoipa::path( +// patch, +// path = "/api/v1/emailers/:id/", +// tag = "emailer", +// params( +// ("id" = i32, Path, description = "The ID of the emailer") +// ), +// responses( +// (status = 200, description = "Successfully updated emailer"), +// (status = 401, description = "Unauthorized"), +// (status = 403, description = "Forbidden"), +// (status = 404, description = "Notifier not found"), +// (status = 500, description = "Internal server error"), +// ) +// )] +// async fn patch_emailer( +// State(ctx): State, +// Path(id): Path, +// session: Session, +// Json(payload): Json, +// ) -> APIResult> { +// // enforce_session_permissions(&session, &[UserPermission::ManageNotifier])?; + +// let client = &ctx.db; + +// unimplemented!() + +// // Ok(Json(SMTPEmailer::try_from(patched_emailer)?)) +// } + +/// Delete an emailer by ID +#[utoipa::path( + delete, + path = "/api/v1/emailers/:id/", + tag = "emailer", + params( + ("id" = i32, Path, description = "The emailer ID"), + ), + responses( + (status = 200, description = "Successfully deleted emailer"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Notifier not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn delete_emailer( + State(ctx): State, + Path(id): Path, + session: Session, +) -> APIResult> { + enforce_session_permissions(&session, &[UserPermission::EmailerManage])?; + + let client = &ctx.db; + + let deleted_emailer = client + .emailer() + .delete(emailer::id::equals(id)) + .exec() + .await?; + + Ok(Json(SMTPEmailer::try_from(deleted_emailer)?)) +} + +#[derive(Debug, Deserialize, ToSchema, Type)] +pub struct EmailerSendRecordIncludeParams { + #[serde(default)] + include_sent_by: bool, +} + +#[utoipa::path( + get, + path = "/api/v1/emailers/:id/send-history", + tag = "emailer", + params( + ("id" = i32, Path, description = "The ID of the emailer") + ), + responses( + (status = 200, description = "Successfully retrieved emailer send history"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error") + ) +)] +async fn get_emailer_send_history( + State(ctx): State, + Path(emailer_id): Path, + QsQuery(include_params): QsQuery, + session: Session, +) -> APIResult>> { + tracing::trace!(?emailer_id, ?include_params, "get_emailer_send_history"); + enforce_session_permissions(&session, &[UserPermission::EmailerRead])?; + + let client = &ctx.db; + + let mut query = client + .emailer_send_record() + .find_many(vec![emailer_send_record::emailer_id::equals(emailer_id)]); + + if include_params.include_sent_by { + query = query.with(emailer_send_record::sent_by::fetch()); + } + + let history = query + .order_by(emailer_send_record::sent_at::order(Direction::Desc)) + .exec() + .await?; + + Ok(Json( + history + .into_iter() + .map(EmailerSendRecord::try_from) + .collect::>>() + .into_iter() + .collect::, _>>()?, + )) +} + +#[derive(Deserialize, ToSchema, Type)] +pub struct SendAttachmentEmailsPayload { + media_ids: Vec, + send_to: Vec, +} + +#[derive(Serialize, ToSchema, Type)] +pub struct SendAttachmentEmailResponse { + sent_emails_count: i32, + errors: Vec, +} + +async fn get_and_validate_recipients( + user: &User, + client: &PrismaClient, + send_to: &[EmailerSendTo], +) -> APIResult> { + let mut recipients = Vec::new(); + for to in send_to { + let recipient = match to { + EmailerSendTo::Device { device_id } => { + let device = client + .registered_email_device() + .find_first(vec![registered_email_device::id::equals(*device_id)]) + .exec() + .await? + .ok_or(APIError::NotFound("Device not found".to_string()))?; + device.email + }, + EmailerSendTo::Anonymous { email } => email.clone(), + }; + recipients.push(recipient); + } + + let forbidden_devices = client + .registered_email_device() + .find_many(vec![registered_email_device::forbidden::equals(true)]) + .exec() + .await?; + let forbidden_recipients = recipients + .iter() + .filter(|r| forbidden_devices.iter().any(|d| d.email == **r)) + .cloned() + .collect::>(); + let has_forbidden_recipients = !forbidden_recipients.is_empty(); + + if has_forbidden_recipients { + tracing::error!( + ?user, + ?forbidden_recipients, + "User attempted to send an email to unauthorized recipient(s)!" + ); + return Err(APIError::forbidden_discreet()); + } + + Ok(recipients) +} + +async fn send_attachment_email( + State(ctx): State, + session: Session, + Json(payload): Json, +) -> APIResult> { + let by_user = enforce_session_permissions( + &session, + &chain_optional_iter( + [UserPermission::EmailSend], + [payload + .send_to + .iter() + .any(|to| matches!(to, EmailerSendTo::Anonymous { .. })) + .then_some(UserPermission::EmailArbitrarySend)], + ), + )?; + + let client = &ctx.db; + + let emailer = client + .emailer() + .find_first(vec![emailer::is_primary::equals(true)]) + .exec() + .await? + .ok_or(APIError::NotFound("Primary emailer not found".to_string()))?; + let emailer = SMTPEmailer::try_from(emailer)?; + let emailer_id = emailer.id; + let max_attachment_size_bytes = emailer.config.max_attachment_size_bytes; + + let expected_books_len = payload.media_ids.len(); + let books = client + .media() + .find_many(apply_media_filters_for_user( + MediaFilter::ids(payload.media_ids), + &by_user, + )) + .exec() + .await? + .into_iter() + .map(Media::from) + .collect::>(); + + if books.len() != expected_books_len { + tracing::error!(?books, ?expected_books_len, "Some media IDs were not found"); + return Err(APIError::BadRequest( + "Some media IDs were not found".to_string(), + )); + } + + let (tx, tx_client) = client._transaction().begin().await?; + let recipients = + match get_and_validate_recipients(&by_user, &tx_client, &payload.send_to).await { + Ok(r) => { + tx.commit(tx_client).await?; + r + }, + Err(e) => { + tx.rollback(tx_client).await?; + return Err(e); + }, + }; + + let emailer_client = emailer.into_client(&ctx).await?; + let mut record_creates = + Vec::<(i32, String, Vec)>::new(); + let mut errors = Vec::new(); + + // TODO: Refactor this to chunk the books and send them in batches according to + // the max attachments per email limit + + for book in books { + let FileParts { + file_name, + extension, + .. + } = PathBuf::from(&book.path).file_parts(); + let content = read_entire_file(book.path)?; + + // TODO: should error? + match (content.len(), max_attachment_size_bytes) { + (_, Some(max_size)) if content.len() as i32 > max_size => { + tracing::warn!("Attachment too large: {} > {}", content.len(), max_size); + continue; + }, + (_, _) if content.len() < 5 => { + tracing::warn!("Attachment too small: {} < 5", content.len()); + continue; + }, + _ => {}, + } + + let content_type = + ContentType::from_bytes_with_fallback(&content[..5], &extension) + .mime_type() + .parse::() + .map_err(|_| { + APIError::InternalServerError( + "Failed to parse content type".to_string(), + ) + })?; + + let attachment_meta = AttachmentMeta::new( + file_name.clone(), + Some(book.id.clone()), + content.len() as i32, + ) + .into_data() + .map_or_else( + |e| { + tracing::error!(?e, "Failed to serialize attachment meta"); + None + }, + Some, + ); + + for recipient in recipients.iter() { + let send_result = emailer_client + .send_attachment( + "Attachment from Stump", + recipient, + AttachmentPayload { + name: file_name.clone(), + content: content.clone(), + content_type: content_type.clone(), + }, + ) + .await; + + match send_result { + Ok(_) => { + record_creates.push(( + emailer_id, + recipient.clone(), + vec![ + emailer_send_record::sent_by::connect(user::id::equals( + by_user.id.clone(), + )), + emailer_send_record::attachment_meta::set( + attachment_meta.clone(), + ), + ], + )); + }, + Err(e) => { + tracing::error!(?e, "Failed to send email"); + errors.push(format!( + "Failed to send {} to {}: {}", + file_name, recipient, e + )); + continue; + }, + } + } + } + + let sent_emails_count = record_creates.len(); + // Note: create_many threw a strange error... + let audit_result = client + ._batch(record_creates.into_iter().map(|(eid, recipient, params)| { + client.emailer_send_record().create( + emailer::id::equals(eid), + recipient, + params, + ) + })) + .await; + if let Err(error) = audit_result { + tracing::error!(?error, "Failed to create emailer send records!"); + errors.push(format!("Failed to create emailer send records: {}", error)); + } + + let updated_emailer_result = client + .emailer() + .update( + emailer::id::equals(emailer_id), + vec![emailer::last_used_at::set(Some(Utc::now().into()))], + ) + .exec() + .await; + if let Err(error) = updated_emailer_result { + tracing::error!(?error, "Failed to update emailer last used at!"); + errors.push(format!("Failed to update emailer last used at: {}", error)); + } + + Ok(Json(SendAttachmentEmailResponse { + sent_emails_count: sent_emails_count as i32, + errors, + })) +} + +/// Get all email devices on the server +#[utoipa::path( + get, + path = "/api/v1/email-devices", + tag = "email-devices", + responses( + (status = 200, description = "Successfully retrieved email devices"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error") + ) +)] +async fn get_email_devices( + State(ctx): State, + session: Session, +) -> APIResult>> { + enforce_session_permissions(&session, &[UserPermission::EmailSend])?; + + let client = &ctx.db; + + let devices = client + .registered_email_device() + .find_many(vec![]) + .exec() + .await?; + + Ok(Json( + devices + .into_iter() + .map(RegisteredEmailDevice::from) + .collect(), + )) +} + +/// Get an email device by its ID +#[utoipa::path( + get, + path = "/api/v1/email-devices/:id", + tag = "email-devices", + params( + ("id" = i32, Path, description = "The ID of the email device") + ), + responses( + (status = 200, description = "Successfully retrieved email device"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Device not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn get_email_device_by_id( + State(ctx): State, + Path(id): Path, + session: Session, +) -> APIResult> { + enforce_session_permissions(&session, &[UserPermission::EmailSend])?; + + let client = &ctx.db; + + let device = client + .registered_email_device() + .find_unique(registered_email_device::id::equals(id)) + .exec() + .await? + .ok_or(APIError::NotFound("Device not found".to_string()))?; + + Ok(Json(RegisteredEmailDevice::from(device))) +} + +/// Input object for creating or updating an email device +#[derive(Deserialize, ToSchema, Type)] +pub struct CreateOrUpdateEmailDevice { + /// The friendly name of the email device, e.g. "Aaron's Kobo" + name: String, + /// The email address of the device + email: String, + /// Whether the device is forbidden from receiving emails from the server. + forbidden: bool, +} + +/// Create a new email device +#[utoipa::path( + post, + path = "/api/v1/email-devices", + tag = "email-devices", + responses( + (status = 200, description = "Successfully created email device"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 500, description = "Internal server error") + ) +)] +async fn create_email_device( + State(ctx): State, + session: Session, + Json(payload): Json, +) -> APIResult> { + enforce_session_permissions(&session, &[UserPermission::EmailerManage])?; + + let client = &ctx.db; + + let device = client + .registered_email_device() + .create( + payload.name, + payload.email, + vec![registered_email_device::forbidden::set(payload.forbidden)], + ) + .exec() + .await?; + + Ok(Json(RegisteredEmailDevice::from(device))) +} + +/// Update an existing email device by its ID +#[utoipa::path( + put, + path = "/api/v1/email-devices/:id", + tag = "email-devices", + params( + ("id" = i32, Path, description = "The ID of the email device") + ), + responses( + (status = 200, description = "Successfully updated email device"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Device not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn update_email_device( + State(ctx): State, + Path(id): Path, + session: Session, + Json(payload): Json, +) -> APIResult> { + enforce_session_permissions(&session, &[UserPermission::EmailerManage])?; + + let client = &ctx.db; + + let device = client + .registered_email_device() + .update( + registered_email_device::id::equals(id), + vec![ + registered_email_device::name::set(payload.name), + registered_email_device::email::set(payload.email), + registered_email_device::forbidden::set(payload.forbidden), + ], + ) + .exec() + .await?; + + Ok(Json(RegisteredEmailDevice::from(device))) +} + +/// Patch an existing email device by its ID +#[derive(Deserialize, ToSchema, Type)] +pub struct PatchEmailDevice { + /// The friendly name of the email device, e.g. "Aaron's Kobo" + pub name: Option, + /// The email address of the device + pub email: Option, + /// Whether the device is forbidden from receiving emails from the server. + pub forbidden: Option, +} + +#[utoipa::path( + patch, + path = "/api/v1/email-devices/:id", + tag = "email-devices", + params( + ("id" = i32, Path, description = "The ID of the email device") + ), + responses( + (status = 200, description = "Successfully patched email device"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Device not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn patch_email_device( + State(ctx): State, + Path(id): Path, + session: Session, + Json(payload): Json, +) -> APIResult> { + enforce_session_permissions(&session, &[UserPermission::EmailerManage])?; + + let client = &ctx.db; + + let device = client + .registered_email_device() + .update( + registered_email_device::id::equals(id), + chain_optional_iter( + [], + [ + payload.name.map(registered_email_device::name::set), + payload.email.map(registered_email_device::email::set), + payload + .forbidden + .map(registered_email_device::forbidden::set), + ], + ), + ) + .exec() + .await?; + + Ok(Json(RegisteredEmailDevice::from(device))) +} + +/// Delete an email device by its ID +#[utoipa::path( + delete, + path = "/api/v1/email-devices/:id", + tag = "email-devices", + params( + ("id" = i32, Path, description = "The ID of the email device") + ), + responses( + (status = 200, description = "Successfully deleted email device"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Device not found"), + (status = 500, description = "Internal server error") + ) +)] +async fn delete_email_device( + State(ctx): State, + Path(id): Path, + session: Session, +) -> APIResult> { + enforce_session_permissions(&session, &[UserPermission::EmailerManage])?; + + let client = &ctx.db; + + let device = client + .registered_email_device() + .delete(registered_email_device::id::equals(id)) + .exec() + .await?; + + Ok(Json(RegisteredEmailDevice::from(device))) +} diff --git a/apps/server/src/routers/api/v1/media.rs b/apps/server/src/routers/api/v1/media.rs index 12cbd64f7..739f74293 100644 --- a/apps/server/src/routers/api/v1/media.rs +++ b/apps/server/src/routers/api/v1/media.rs @@ -787,7 +787,7 @@ async fn get_media_file( ) -> APIResult { let db = &ctx.db; - let user = get_session_user(&session)?; + let user = enforce_session_permissions(&session, &[UserPermission::DownloadFile])?; let age_restrictions = user .age_restriction .as_ref() diff --git a/apps/server/src/routers/api/v1/mod.rs b/apps/server/src/routers/api/v1/mod.rs index 26fc42e32..494186d39 100644 --- a/apps/server/src/routers/api/v1/mod.rs +++ b/apps/server/src/routers/api/v1/mod.rs @@ -15,6 +15,7 @@ use crate::{ pub(crate) mod auth; pub(crate) mod book_club; +pub(crate) mod emailer; pub(crate) mod epub; pub(crate) mod filesystem; pub(crate) mod job; @@ -33,6 +34,7 @@ pub(crate) fn mount(app_state: AppState) -> Router { Router::new() .merge(auth::mount()) .merge(epub::mount(app_state.clone())) + .merge(emailer::mount(app_state.clone())) .merge(library::mount(app_state.clone())) .merge(media::mount(app_state.clone())) .merge(metadata::mount(app_state.clone())) diff --git a/apps/server/src/routers/api/v1/notifier.rs b/apps/server/src/routers/api/v1/notifier.rs index 1776fe78a..ccf8d3667 100644 --- a/apps/server/src/routers/api/v1/notifier.rs +++ b/apps/server/src/routers/api/v1/notifier.rs @@ -7,7 +7,7 @@ use axum::{ use serde::Deserialize; use specta::Type; use stump_core::{ - db::entity::{Notifier, NotifierConfig, NotifierType, UserPermission}, + db::entity::{Notifier, NotifierConfigInput, NotifierType, UserPermission}, prisma::notifier, }; use tower_sessions::Session; @@ -108,7 +108,7 @@ async fn get_notifier_by_id( pub struct CreateOrUpdateNotifier { #[serde(rename = "type")] _type: NotifierType, - config: NotifierConfig, + config: NotifierConfigInput, } #[utoipa::path( @@ -132,14 +132,10 @@ async fn create_notifier( enforce_session_permissions(&session, &[UserPermission::CreateNotifier])?; let client = &ctx.db; - + let config = payload.config.into_config(&ctx).await?.into_bytes()?; let notifier = client .notifier() - .create( - payload._type.to_string(), - payload.config.into_bytes()?, - vec![], - ) + .create(payload._type.to_string(), config, vec![]) .exec() .await?; @@ -172,13 +168,14 @@ async fn update_notifier( enforce_session_permissions(&session, &[UserPermission::ManageNotifier])?; let client = &ctx.db; + let config = payload.config.into_config(&ctx).await?.into_bytes()?; let notifier = client .notifier() .update( notifier::id::equals(id), vec![ notifier::r#type::set(payload._type.to_string()), - notifier::config::set(payload.config.into_bytes()?), + notifier::config::set(config), ], ) .exec() @@ -191,7 +188,7 @@ async fn update_notifier( pub struct PatchNotifier { #[serde(rename = "type")] _type: Option, - config: Option, + config: Option, } #[utoipa::path( @@ -219,10 +216,11 @@ async fn patch_notifier( let client = &ctx.db; - let config = payload - .config - .map(|config| config.into_bytes()) - .transpose()?; + let config = if let Some(config) = payload.config { + Some(config.into_config(&ctx).await?.into_bytes()?) + } else { + None + }; let patched_notifier = client .notifier() diff --git a/core/Cargo.toml b/core/Cargo.toml index 7dfb1c5f7..a26093c3e 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -4,52 +4,47 @@ version = { workspace = true } edition = "2021" [dependencies] -tokio = { workspace = true } -serde = { workspace = true } -prisma-client-rust = { workspace = true } -specta = { workspace = true } - -### Async Utils ### -rayon = "1.8.0" -futures = { workspace = true } -async-trait = { workspace = true } +alphanumeric-sort = "1.5.3" async-channel = "2.1.0" - -### Filesystem Utils ### -walkdir = "2.4.0" -globset = "0.4.14" +async-trait = { workspace = true } +cuid = "1.3.2" +data-encoding = "2.5.0" dirs = "5.0.1" +email = { path = "../crates/email" } +epub = { git = "https://github.com/stumpapp/epub-rs", rev = "38e091abe96875952556ab7dec195022d0230e14" } +futures = { workspace = true } +globset = "0.4.14" +image = "0.24.7" +infer = "0.15.0" +itertools = "0.12.0" +prisma-client-rust = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde-xml-rs = "0.6.0" # Support for XML serialization/deserialization +serde_json = { workspace = true } +simple_crypt = { workspace = true } +specta = { workspace = true } +tokio = { workspace = true } toml = "0.8.8" trash = "3.1.2" -infer = "0.15.0" -image = "0.24.7" -webp = "0.2.6" -zip = "0.6.6" -epub = { git = "https://github.com/stumpapp/epub-rs", rev = "38e091abe96875952556ab7dec195022d0230e14" } -unrar = { version = "0.5.2" } # pdf = "0.8.1" pdf = { git = "https://github.com/pdf-rs/pdf", rev = "3bc9e636d31b1846e51b58c7429914e640866f53" } # TODO: revert back to crates.io once fix(es) release pdfium-render = "0.8.16" -data-encoding = "2.5.0" +rayon = "1.8.0" +regex = "1.10.2" ring = "0.17.7" - -### Errors and Logging ### thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-appender = "0.2.3" - -### Misc Utils ### +unrar = { version = "0.5.2" } urlencoding = { workspace = true } -cuid = "1.3.2" -xml-rs = "0.8.19" # XML reader/writer -serde-xml-rs = "0.6.0" # Support for XML serialization/deserialization -serde_json = { workspace = true } -itertools = "0.12.0" utoipa = { version = "3.5.0" } uuid = "1.6.1" -regex = "1.10.2" -alphanumeric-sort = "1.5.3" +walkdir = "2.4.0" +webp = "0.2.6" +xml-rs = "0.8.19" # XML reader/writer +zip = "0.6.6" [dev-dependencies] tempfile = { workspace = true } diff --git a/core/prisma/migrations/20240412235240_emailer_and_encryption/migration.sql b/core/prisma/migrations/20240412235240_emailer_and_encryption/migration.sql new file mode 100644 index 000000000..a11777696 --- /dev/null +++ b/core/prisma/migrations/20240412235240_emailer_and_encryption/migration.sql @@ -0,0 +1,45 @@ +-- AlterTable +ALTER TABLE "server_config" ADD COLUMN "encryption_key" TEXT; + +-- CreateTable +CREATE TABLE "registered_email_devices" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "forbidden" BOOLEAN NOT NULL DEFAULT false +); + +-- CreateTable +CREATE TABLE "emailer_send_records" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "emailer_id" INTEGER NOT NULL, + "recipient_email" TEXT NOT NULL, + "attachment_meta" BLOB, + "sent_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sent_by_user_id" TEXT, + CONSTRAINT "emailer_send_records_emailer_id_fkey" FOREIGN KEY ("emailer_id") REFERENCES "emailers" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "emailer_send_records_sent_by_user_id_fkey" FOREIGN KEY ("sent_by_user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "emailers" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "is_primary" BOOLEAN NOT NULL DEFAULT false, + "sender_email" TEXT NOT NULL, + "sender_display_name" TEXT NOT NULL, + "username" TEXT NOT NULL, + "encrypted_password" TEXT NOT NULL, + "smtp_host" TEXT NOT NULL, + "smtp_port" INTEGER NOT NULL, + "tls_enabled" BOOLEAN NOT NULL DEFAULT false, + "max_attachment_size_bytes" INTEGER, + "max_num_attachments" INTEGER, + "last_used_at" DATETIME +); + +-- CreateIndex +CREATE UNIQUE INDEX "registered_email_devices_name_key" ON "registered_email_devices"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "emailers_name_key" ON "emailers"("name"); diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index bcf469f7e..66308b106 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { library_visits LastLibraryVisit[] smart_lists SmartList[] smart_list_access_rules SmartListAccessRule[] + email_usage_history EmailerSendRecord[] @@map("users") } @@ -691,7 +692,7 @@ model UserPreferences { model JobScheduleConfig { id String @id @default(cuid()) - // The interval (in seconds) in which to run the scheduled confu + // The interval (in seconds) in which to run the scheduled configuration interval_secs Int @default(86400) // The libraries to exclude from scheduled scans, if any @@ -705,13 +706,61 @@ model JobScheduleConfig { model Notifier { id Int @id @default(autoincrement()) - type String //DISCORD | TELEGRAM - config Bytes //too many variants to support concrete type + type String // DISCORD | TELEGRAM + config Bytes // There will be too many variants to support concrete type(s) @@map("notifiers") } -// TODO: notifier support +model RegisteredEmailDevice { + id Int @id @default(autoincrement()) + + name String @unique + email String + forbidden Boolean @default(false) + + @@map("registered_email_devices") +} + +model EmailerSendRecord { + id Int @id @default(autoincrement()) + + emailer_id Int + emailer Emailer @relation(fields: [emailer_id], references: [id], onDelete: Cascade) + + recipient_email String + attachment_meta Bytes? // { name: "...", size: ... } + sent_at DateTime @default(now()) + + sent_by_user_id String? + sent_by User? @relation(fields: [sent_by_user_id], references: [id], onDelete: Cascade) + + @@map("emailer_send_records") +} + +model Emailer { + id Int @id @default(autoincrement()) + + name String @unique + is_primary Boolean @default(false) + + sender_email String + sender_display_name String + username String + encrypted_password String + smtp_host String + smtp_port Int + tls_enabled Boolean @default(false) + max_attachment_size_bytes Int? // null = unlimited + max_num_attachments Int? // null = unlimited + + last_used_at DateTime? + + send_history EmailerSendRecord[] + + @@map("emailers") +} + // An external invitation sent to a provided email for the user to join the server model ServerInvitation { id String @id @default(cuid()) @@ -722,8 +771,6 @@ model ServerInvitation { created_at DateTime @default(now()) expires_at DateTime - // notifier Notifier? @relation(fields: [notifier_id], references: [id]) - @@map("server_invitations") } @@ -733,6 +780,10 @@ model ServerConfig { public_url String? // The public URL of the server, if any initial_wal_setup_complete Boolean @default(false) // Whether the initial WAL setup has been completed + // TODO: For obvious reasons, this is severely insecure lol i.e. don't store an encryption key in the database... + // However, I don't have a better solution at the moment. This, at best, provides a small barrier to entry I guess + // for bad actors. I am not overly knowledgeable in cryptography, so I'm not sure what the best solution is here. + encryption_key String? // The encryption key used to encrypt sensitive data // TODO: make this an array, so we can support multiple job types and not assume it will only ever be scheduled scan // The schedule configuration. If not set, no scheduled scans will be run. diff --git a/core/src/config/stump_config.rs b/core/src/config/stump_config.rs index 128e9e26b..2289869b2 100644 --- a/core/src/config/stump_config.rs +++ b/core/src/config/stump_config.rs @@ -22,8 +22,6 @@ pub mod env_keys { pub const SESSION_TTL_KEY: &str = "SESSION_TTL"; pub const SESSION_EXPIRY_INTERVAL_KEY: &str = "SESSION_EXPIRY_CLEANUP_INTERVAL"; pub const SCANNER_CHUNK_SIZE_KEY: &str = "STUMP_SCANNER_CHUNK_SIZE"; - pub const ENABLE_EXPERIMENTAL_CONCURRENCY_KEY: &str = - "ENABLE_EXPERIMENTAL_CONCURRENCY"; } use env_keys::*; @@ -75,6 +73,8 @@ pub struct StumpConfig { pub db_path: Option, /// The client directory. pub client_dir: String, + /// An optional custom path for the templates directory. + pub custom_templates_dir: Option, /// The configuration root for the Stump application, cotains thumbnails, cache, and logs. pub config_dir: String, /// A list of origins for CORS. @@ -107,6 +107,7 @@ impl StumpConfig { db_path: None, client_dir: String::from("./dist"), config_dir, + custom_templates_dir: None, allowed_origins: vec![], pdfium_path: None, disable_swagger: false, @@ -129,6 +130,7 @@ impl StumpConfig { db_path: None, client_dir: env!("CARGO_MANIFEST_DIR").to_string() + "/../web/dist", config_dir: super::get_default_config_dir(), + custom_templates_dir: None, allowed_origins: vec![], pdfium_path: None, disable_swagger: false, @@ -234,6 +236,10 @@ impl StumpConfig { env_configs.pdfium_path = Some(pdfium_path); } + if let Ok(custom_templates_dir) = env::var("EMAIL_TEMPLATES_DIR") { + self.custom_templates_dir = Some(custom_templates_dir); + } + if let Ok(hash_cost) = env::var(HASH_COST_KEY) { if let Ok(val) = hash_cost.parse() { env_configs.password_hash_cost = Some(val); @@ -355,6 +361,14 @@ impl StumpConfig { PathBuf::from(&self.config_dir).join("thumbnails") } + /// Returns a `PathBuf` to the Stump templates directory. + pub fn get_templates_dir(&self) -> PathBuf { + self.custom_templates_dir.clone().map_or_else( + || PathBuf::from(&self.config_dir).join("templates"), + PathBuf::from, + ) + } + /// Returns a `PathBuf` to the Stump avatars directory pub fn get_avatars_dir(&self) -> PathBuf { PathBuf::from(&self.config_dir).join("avatars") @@ -512,6 +526,7 @@ mod tests { db_path: Some("not_a_real_path".to_string()), client_dir: "not_a_real_dir".to_string(), config_dir: "also_not_a_real_dir".to_string(), + custom_templates_dir: None, allowed_origins: vec![ "origin1".to_string(), "origin2".to_string(), @@ -557,6 +572,7 @@ mod tests { db_path: Some("not_a_real_path".to_string()), client_dir: "not_a_real_dir".to_string(), config_dir: "also_not_a_real_dir".to_string(), + custom_templates_dir: None, allowed_origins: vec![], pdfium_path: None, disable_swagger: true, @@ -591,6 +607,7 @@ mod tests { db_path: Some("not_a_real_path".to_string()), client_dir: "not_a_real_dir".to_string(), config_dir: "also_not_a_real_dir".to_string(), + custom_templates_dir: None, allowed_origins: vec!["origin1".to_string(), "origin2".to_string()], pdfium_path: Some("not_a_path_to_pdfium".to_string()), disable_swagger: false, diff --git a/core/src/context.rs b/core/src/context.rs index 477cc3c2c..0c463125f 100644 --- a/core/src/context.rs +++ b/core/src/context.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use prisma_client_rust::not; use tokio::sync::{ broadcast::{channel, Receiver, Sender}, mpsc::error::SendError, @@ -10,7 +11,8 @@ use crate::{ db, event::CoreEvent, job::{Executor, JobController, JobControllerCommand}, - prisma, + prisma::{self, server_config}, + CoreError, CoreResult, }; type EventChannel = (Sender, Receiver); @@ -177,4 +179,19 @@ impl Ctx { tracing::trace!("Sent core event"); } } + + pub async fn get_encryption_key(&self) -> CoreResult { + let server_config = self + .db + .server_config() + .find_first(vec![not![server_config::encryption_key::equals(None)]]) + .exec() + .await?; + + let encryption_key = server_config + .and_then(|config| config.encryption_key) + .ok_or(CoreError::EncryptionKeyNotSet)?; + + Ok(encryption_key) + } } diff --git a/core/src/db/entity/emailer/device.rs b/core/src/db/entity/emailer/device.rs new file mode 100644 index 000000000..851ea64de --- /dev/null +++ b/core/src/db/entity/emailer/device.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use utoipa::ToSchema; + +use crate::prisma::registered_email_device; + +#[derive(Serialize, Deserialize, ToSchema, Type)] +pub struct RegisteredEmailDevice { + id: i32, + name: String, + email: String, + forbidden: bool, +} + +impl From for RegisteredEmailDevice { + fn from(data: registered_email_device::Data) -> Self { + Self { + id: data.id, + name: data.name, + email: data.email, + forbidden: data.forbidden, + } + } +} diff --git a/core/src/db/entity/emailer/entity.rs b/core/src/db/entity/emailer/entity.rs new file mode 100644 index 000000000..165fbb1fe --- /dev/null +++ b/core/src/db/entity/emailer/entity.rs @@ -0,0 +1,128 @@ +use email::{EmailerClient, EmailerClientConfig}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use utoipa::ToSchema; + +use crate::{ + prisma::emailer, + utils::{decrypt_string, encrypt_string}, + CoreError, CoreResult, Ctx, +}; + +/// The config for an SMTP emailer +#[derive(Serialize, Deserialize, ToSchema, Type)] +pub struct EmailerConfig { + /// The email address to send from + pub sender_email: String, + /// The display name to use for the sender + pub sender_display_name: String, + /// The username to use for the SMTP server, typically the same as the sender email + pub username: String, + /// The encrypted password to use for the SMTP server + #[serde(skip_serializing)] + pub encrypted_password: String, + /// The SMTP host to use + pub smtp_host: String, + /// The SMTP port to use + pub smtp_port: u16, + /// Whether to use TLS for the SMTP connection + pub tls_enabled: bool, + /// The maximum size of an attachment in bytes + pub max_attachment_size_bytes: Option, + /// The maximum number of attachments that can be sent in a single email + pub max_num_attachments: Option, +} + +impl EmailerConfig { + /// Convert the config into a client config, which is used for the actual sending of emails + pub async fn into_client_config(self, ctx: &Ctx) -> CoreResult { + let password = decrypt_string(&self.encrypted_password, ctx).await?; + Ok(EmailerClientConfig { + sender_email: self.sender_email, + sender_display_name: self.sender_display_name, + username: self.username, + password, + host: self.smtp_host, + port: self.smtp_port, + tls_enabled: self.tls_enabled, + max_attachment_size_bytes: self.max_attachment_size_bytes, + max_num_attachments: self.max_num_attachments, + }) + } + + pub async fn from_client_config( + config: EmailerClientConfig, + ctx: &Ctx, + ) -> CoreResult { + let encrypted_password = encrypt_string(&config.password, ctx).await?; + Ok(EmailerConfig { + sender_email: config.sender_email, + sender_display_name: config.sender_display_name, + username: config.username, + encrypted_password, + smtp_host: config.host, + smtp_port: config.port, + tls_enabled: config.tls_enabled, + max_attachment_size_bytes: config.max_attachment_size_bytes, + max_num_attachments: config.max_num_attachments, + }) + } +} + +pub type EmailerConfigInput = EmailerClientConfig; + +/// An SMTP emailer entity, which stores SMTP configuration data to be used for sending emails. +/// +// Stump supports multiple emailers, however for the initial POC of this feature only one emailer +/// will be configurable. This will be expanded in the future. +#[derive(Serialize, Deserialize, ToSchema, Type)] +pub struct SMTPEmailer { + pub id: i32, + /// The friendly name for the emailer, used primarily to identify it in the UI + pub name: String, + /// Whether the emailer is the primary emailer for the system + pub is_primary: bool, + /// The configuration for the emailer + pub config: EmailerConfig, + /// The last time the emailer was used + pub last_used_at: Option, +} + +impl SMTPEmailer { + pub async fn into_client(self, ctx: &Ctx) -> CoreResult { + let config = self.config.into_client_config(ctx).await?; + let template_dir = ctx.config.get_templates_dir(); + Ok(EmailerClient::new(config, template_dir)) + } +} + +#[derive(Serialize, Deserialize, ToSchema, Type)] +#[serde(untagged)] +pub enum EmailerSendTo { + Device { device_id: i32 }, + Anonymous { email: String }, +} + +impl TryFrom for SMTPEmailer { + type Error = CoreError; + + fn try_from(data: emailer::Data) -> Result { + Ok(SMTPEmailer { + id: data.id, + name: data.name, + is_primary: data.is_primary, + config: EmailerConfig { + sender_email: data.sender_email, + sender_display_name: data.sender_display_name, + username: data.username, + encrypted_password: data.encrypted_password, + smtp_host: data.smtp_host, + smtp_port: data.smtp_port as u16, + tls_enabled: data.tls_enabled, + max_attachment_size_bytes: data.max_attachment_size_bytes, + max_num_attachments: data.max_num_attachments, + }, + last_used_at: data.last_used_at.map(|t| t.to_rfc3339()), + }) + } +} diff --git a/core/src/db/entity/emailer/history.rs b/core/src/db/entity/emailer/history.rs new file mode 100644 index 000000000..49787eb5b --- /dev/null +++ b/core/src/db/entity/emailer/history.rs @@ -0,0 +1,83 @@ +use crate::{db::entity::User, CoreError, CoreResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use utoipa::ToSchema; + +use crate::prisma::emailer_send_record; + +/// The metadata of an attachment that was sent with an email +#[derive(Serialize, Deserialize, ToSchema, Type)] +pub struct AttachmentMeta { + /// The filename of the attachment + pub filename: String, + /// The associated media ID of the attachment, if there is one + pub media_id: Option, + /// The size of the attachment in bytes + pub size: i32, +} + +impl AttachmentMeta { + /// Create a new attachment meta + pub fn new(filename: String, media_id: Option, size: i32) -> Self { + Self { + filename, + media_id, + size, + } + } + + // TODO: This is a little awkward, and will have to change once emails properly send + // multiple attachments at once + /// Convert the attachment meta into a byte array, wrapped in a vec + pub fn into_data(&self) -> CoreResult> { + serde_json::to_vec(&vec![self]).map_err(CoreError::from) + } +} + +/// A record of an email that was sent, used to keep track of emails that +/// were sent by specific emailer(s) +#[derive(Serialize, Deserialize, ToSchema, Type)] +pub struct EmailerSendRecord { + /// The ID of this record + id: i32, + /// The ID of the emailer that sent this email + emailer_id: i32, + /// The email of the recipient of this email + recipient_email: String, + /// The metadata of the attachment, if there is one + attachment_meta: Option>, + /// The timestamp of when this email was sent + sent_at: String, + /// The user ID of the user that sent this email + sent_by_user_id: Option, + /// The user that sent this email + #[serde(skip_serializing_if = "Option::is_none")] + sent_by: Option, +} + +impl TryFrom for EmailerSendRecord { + type Error = CoreError; + + fn try_from(data: emailer_send_record::Data) -> Result { + let sent_by = data.sent_by().ok().flatten().cloned().map(User::from); + let attachment_meta = data.attachment_meta.as_deref().and_then(|data| { + serde_json::from_slice(data).map_or_else( + |error| { + tracing::error!(?error, "Failed to deserialize attachment meta"); + None + }, + Some, + ) + }); + + Ok(Self { + id: data.id, + emailer_id: data.emailer_id, + recipient_email: data.recipient_email, + attachment_meta, + sent_at: data.sent_at.to_rfc3339(), + sent_by_user_id: data.sent_by_user_id.map(|id| id.to_string()), + sent_by, + }) + } +} diff --git a/core/src/db/entity/emailer/mod.rs b/core/src/db/entity/emailer/mod.rs new file mode 100644 index 000000000..06d24ac70 --- /dev/null +++ b/core/src/db/entity/emailer/mod.rs @@ -0,0 +1,7 @@ +mod device; +mod entity; +mod history; + +pub use device::*; +pub use entity::*; +pub use history::*; diff --git a/core/src/db/entity/mod.rs b/core/src/db/entity/mod.rs index 3c6b2993c..6f41d3df6 100644 --- a/core/src/db/entity/mod.rs +++ b/core/src/db/entity/mod.rs @@ -1,5 +1,6 @@ mod book_club; pub(crate) mod common; +mod emailer; mod epub; mod job; mod library; @@ -18,6 +19,7 @@ pub use self::epub::*; pub use self::log::*; pub use book_club::*; +pub use emailer::*; pub use job::*; pub use library::*; pub use media::*; diff --git a/core/src/db/entity/notifier.rs b/core/src/db/entity/notifier.rs index bef07228b..ced452374 100644 --- a/core/src/db/entity/notifier.rs +++ b/core/src/db/entity/notifier.rs @@ -1,4 +1,4 @@ -use crate::{prisma::notifier, CoreError}; +use crate::{prisma::notifier, utils::encrypt_string, CoreError, CoreResult, Ctx}; use serde::{Deserialize, Serialize}; use specta::Type; use std::str::FromStr; @@ -6,24 +6,39 @@ use utoipa::ToSchema; #[derive(Serialize, Deserialize, ToSchema, Type)] pub struct Notifier { + /// The ID of the notifier id: i32, // Note: This isn't really needed, we could rely on tags. However, in order to have at least one // readable field in the DB (since the config is dumped to bytes) I left this in #[serde(rename = "type")] _type: NotifierType, + /// The config is stored as bytes in the DB, and is deserialized into the correct type when + /// needed. If there are sensitive fields, they should be encrypted before being stored. config: NotifierConfig, } +/// The config for a Discord notifier +#[derive(Serialize, Deserialize, ToSchema, Type)] +pub struct DiscordConfig { + /// The webhook URL to send to + pub webhook_url: String, +} + +/// The config for a Telegram notifier +#[derive(Serialize, Deserialize, ToSchema, Type)] +pub struct TelegramConfig { + /// The encrypted token to use for the Telegram bot. This is encrypted before being stored, + /// and decrypted when needed. + pub encrypted_token: String, + /// The chat ID to send to + pub chat_id: String, +} + #[derive(Serialize, Deserialize, ToSchema, Type)] #[serde(untagged)] pub enum NotifierConfig { - Discord { - webhook_url: String, - }, - Telegram { - encrypted_token: String, - chat_id: String, - }, + Discord(DiscordConfig), + Telegram(TelegramConfig), } impl NotifierConfig { @@ -32,6 +47,34 @@ impl NotifierConfig { } } +#[derive(Serialize, Deserialize, ToSchema, Type)] +pub struct TelegramConfigInput { + pub token: String, + pub chat_id: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Type)] +#[serde(untagged)] +pub enum NotifierConfigInput { + Discord(DiscordConfig), + Telegram(TelegramConfigInput), +} + +impl NotifierConfigInput { + pub async fn into_config(self, ctx: &Ctx) -> CoreResult { + match self { + NotifierConfigInput::Discord(config) => Ok(NotifierConfig::Discord(config)), + NotifierConfigInput::Telegram(config) => { + let encrypted_token = encrypt_string(&config.token, ctx).await?; + Ok(NotifierConfig::Telegram(TelegramConfig { + encrypted_token, + chat_id: config.chat_id, + })) + }, + } + } +} + #[derive(Serialize, Deserialize, ToSchema, Type)] pub enum NotifierType { #[serde(rename = "DISCORD")] diff --git a/core/src/db/entity/user/entity.rs b/core/src/db/entity/user/entity.rs index e4bb8fcc5..439b26bde 100644 --- a/core/src/db/entity/user/entity.rs +++ b/core/src/db/entity/user/entity.rs @@ -7,7 +7,9 @@ use crate::{ prisma, }; -use super::{AgeRestriction, LoginActivity, UserPermission, UserPreferences}; +use super::{ + AgeRestriction, LoginActivity, PermissionSet, UserPermission, UserPreferences, +}; #[derive(Default, Debug, Clone, Serialize, Deserialize, Type, ToSchema)] pub struct User { @@ -71,19 +73,14 @@ impl From for User { let login_sessions_count = data.sessions().map(|sessions| sessions.len() as i32).ok(); + let permission_set = data.permissions.map(PermissionSet::from); + User { id: data.id, username: data.username, is_server_owner: data.is_server_owner, - permissions: data - .permissions - .map(|p| { - p.split(',') - .map(|p| p.trim()) - .filter(|p| !p.is_empty()) - .map(|p| p.into()) - .collect() - }) + permissions: permission_set + .map(|ps| ps.resolve_into_vec()) .unwrap_or_default(), max_sessions_allowed: data.max_sessions_allowed, user_preferences, diff --git a/core/src/db/entity/user/permissions.rs b/core/src/db/entity/user/permissions.rs index 86fa06615..74640acb1 100644 --- a/core/src/db/entity/user/permissions.rs +++ b/core/src/db/entity/user/permissions.rs @@ -1,3 +1,4 @@ +use itertools::Itertools; use serde::{Deserialize, Serialize}; use specta::Type; use utoipa::ToSchema; @@ -22,7 +23,9 @@ impl From for AgeRestriction { // TODO: consider adding self:update permission, useful for child accounts /// Permissions that can be granted to a user. Some permissions are implied by others, /// and will be automatically granted if the "parent" permission is granted. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, ToSchema, Eq, PartialEq)] +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, Type, ToSchema, Eq, PartialEq, Hash, +)] pub enum UserPermission { ///TODO: Expand permissions for bookclub + smartlist /// Grant access to the book club feature @@ -31,6 +34,21 @@ pub enum UserPermission { /// Grant access to create a book club (access book club) #[serde(rename = "bookclub:create")] CreateBookClub, + /// Grant access to read any emailers in the system + #[serde(rename = "emailer:read")] + EmailerRead, + /// Grant access to create an emailer + #[serde(rename = "emailer:create")] + EmailerCreate, + /// Grant access to manage an emailer + #[serde(rename = "emailer:manage")] + EmailerManage, + /// Grant access to send an email + #[serde(rename = "email:send")] + EmailSend, + /// Grant access to send an arbitrary email, bypassing any registered device requirements + #[serde(rename = "email:arbitrary_send")] + EmailArbitrarySend, /// Grant access to access the smart list feature. This includes the ability to create and edit smart lists #[serde(rename = "smartlist:read")] AccessSmartList, @@ -91,6 +109,12 @@ impl UserPermission { pub fn associated(&self) -> Vec { match self { UserPermission::CreateBookClub => vec![UserPermission::AccessBookClub], + UserPermission::EmailerRead => vec![UserPermission::EmailSend], + UserPermission::EmailerCreate => vec![UserPermission::EmailerRead], + UserPermission::EmailerManage => { + vec![UserPermission::EmailerCreate, UserPermission::EmailerRead] + }, + UserPermission::EmailArbitrarySend => vec![UserPermission::EmailSend], UserPermission::CreateLibrary => { vec![UserPermission::EditLibrary, UserPermission::ScanLibrary] }, @@ -123,6 +147,11 @@ impl ToString for UserPermission { match self { UserPermission::AccessBookClub => "bookclub:read".to_string(), UserPermission::CreateBookClub => "bookclub:create".to_string(), + UserPermission::EmailerRead => "emailer:read".to_string(), + UserPermission::EmailerCreate => "emailer:create".to_string(), + UserPermission::EmailerManage => "emailer:manage".to_string(), + UserPermission::EmailSend => "email:send".to_string(), + UserPermission::EmailArbitrarySend => "email:arbitrary_send".to_string(), UserPermission::AccessSmartList => "smartlist:read".to_string(), UserPermission::FileExplorer => "file:explorer".to_string(), UserPermission::UploadFile => "file:upload".to_string(), @@ -148,6 +177,11 @@ impl From<&str> for UserPermission { match s { "bookclub:read" => UserPermission::AccessBookClub, "bookclub:create" => UserPermission::CreateBookClub, + "emailer:read" => UserPermission::EmailerRead, + "emailer:create" => UserPermission::EmailerCreate, + "emailer:manage" => UserPermission::EmailerManage, + "email:send" => UserPermission::EmailSend, + "email:arbitrary_send" => UserPermission::EmailArbitrarySend, "smartlist:read" => UserPermission::AccessSmartList, "file:explorer" => UserPermission::FileExplorer, "file:upload" => UserPermission::UploadFile, @@ -164,7 +198,39 @@ impl From<&str> for UserPermission { "notifier:manage" => UserPermission::ManageNotifier, "notifier:delete" => UserPermission::DeleteNotifier, "server:manage" => UserPermission::ManageServer, + // FIXME: Don't panic smh _ => panic!("Invalid user permission: {}", s), } } } + +/// A wrapper around a Vec used for including any associated permissions +/// from the underlying permissions +#[derive(Debug, Serialize, Deserialize, ToSchema, Type)] +pub struct PermissionSet(Vec); + +impl PermissionSet { + /// Unwrap the underlying Vec and include any associated permissions + pub fn resolve_into_vec(self) -> Vec { + self.0 + .into_iter() + .flat_map(|permission| { + let mut v = vec![permission]; + v.extend(permission.associated()); + v + }) + .unique() + .collect() + } +} + +impl From for PermissionSet { + fn from(s: String) -> PermissionSet { + let permissions = s + .split(',') + .map(|s| s.trim()) + .map(UserPermission::from) + .collect(); + PermissionSet(permissions) + } +} diff --git a/core/src/error.rs b/core/src/error.rs index e8ce28a6e..a28d86e12 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -7,12 +7,18 @@ pub type CoreResult = Result; #[derive(Error, Debug)] pub enum CoreError { - #[error("Failed to initialize Stump core: {0}")] - InitializationError(String), #[error( "Attempted to initialize StumpCore with a config dir that does not exist: {0}" )] ConfigDirDoesNotExist(String), + #[error("Encryption key must be set")] + EncryptionKeyNotSet, + #[error("Failed to encrypt: {0}")] + EncryptionFailed(String), + #[error("Failed to decrypt: {0}")] + DecryptionFailed(String), + #[error("Failed to initialize Stump core: {0}")] + InitializationError(String), #[error("Query error: {0}")] QueryError(#[from] prisma_client_rust::queries::QueryError), #[error("Invalid query error: {0}")] diff --git a/core/src/lib.rs b/core/src/lib.rs index be1f61706..07a2a6999 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -32,8 +32,14 @@ pub use context::Ctx; pub use error::{CoreError, CoreResult}; pub use event::CoreEvent; +pub use email::{ + AttachmentPayload, EmailContentType, EmailerClient, EmailerClientConfig, +}; + /// A type alias strictly for explicitness in the return type of `init_journal_mode`. type JournalModeChanged = bool; +/// A type alias strictly for explicitness in the return type of `init_encryption`. +type EncryptionKeySet = bool; /// The [StumpCore] struct is the main entry point for any server-side Stump /// applications. It is responsible for managing incoming tasks ([InternalCoreTask]), @@ -138,6 +144,40 @@ impl StumpCore { Ok(()) } + // TODO: This is insecure for obvious reasons, and should be removed in the future. This was added + // to reduce friction of setting up the server for folks who might not understand encryption keys. + /// Initializes the encryption key for the database. This will only set the encryption key + /// if one does not already exist. + pub async fn init_encryption(&self) -> Result { + let client = self.ctx.db.clone(); + + let encryption_key_set = client + .server_config() + .find_first(vec![server_config::encryption_key::not(None)]) + .exec() + .await? + .is_some(); + + if encryption_key_set { + Ok(false) + } else { + let encryption_key = utils::create_encryption_key()?; + let affected_rows = client + .server_config() + .update_many( + vec![], + vec![server_config::encryption_key::set(Some(encryption_key))], + ) + .exec() + .await?; + tracing::trace!(affected_rows, "Updated encryption key"); + if affected_rows > 1 { + tracing::warn!("More than one encryption key was updated? This is definitely not expected"); + } + Ok(affected_rows > 0) + } + } + /// Initializes the journal mode for the database. This will only set the journal mode to WAL /// provided a few conditions are met: /// @@ -196,6 +236,7 @@ impl StumpCore { mod tests { use std::{fs::File, io::Write, path::PathBuf}; + use email::EmailerClientConfig; use specta::{ ts::{export, BigIntExportBehavior, ExportConfiguration, TsExportError}, NamedType, @@ -249,7 +290,7 @@ mod tests { file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; // file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; - // TODO: Fix this... Must move all job defs to the core... + // TODO: Fix this... Must move all job defs to the core... Otherwise, the `unknown` type swallows the others in the union file.write_all( "export type CoreJobOutput = LibraryScanOutput | SeriesScanOutput | ThumbnailGenerationOutput\n\n".to_string() .as_bytes(), @@ -274,6 +315,19 @@ mod tests { file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; + file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; + file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + + file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; + file.write_all( + format!("{}\n\n", ts_export::()?).as_bytes(), + )?; + file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; + file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; + file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; file.write_all(format!("{}\n\n", ts_export::()?).as_bytes())?; diff --git a/core/src/utils.rs b/core/src/utils.rs index b4884f150..6123ac606 100644 --- a/core/src/utils.rs +++ b/core/src/utils.rs @@ -1,3 +1,7 @@ +use simple_crypt::{decrypt, encrypt}; + +use crate::{CoreError, CoreResult, Ctx}; + pub fn chain_optional_iter( required: impl IntoIterator, optional: impl IntoIterator>, @@ -9,3 +13,27 @@ pub fn chain_optional_iter( .flatten() .collect() } + +pub fn create_encryption_key() -> CoreResult { + let random_bytes = rand::random::<[u8; 32]>(); + + Ok(data_encoding::BASE64.encode(&random_bytes)) +} + +pub async fn encrypt_string(str: &str, ctx: &Ctx) -> CoreResult { + let encryption_key = ctx.get_encryption_key().await?; + let encrypted_bytes = encrypt(str.as_bytes(), encryption_key.as_bytes()) + .map_err(|e| CoreError::EncryptionFailed(e.to_string()))?; + Ok(data_encoding::BASE64.encode(&encrypted_bytes)) +} + +pub async fn decrypt_string(encrypted_str: &str, ctx: &Ctx) -> CoreResult { + let encryption_key = ctx.get_encryption_key().await?; + let encrypted_bytes = data_encoding::BASE64 + .decode(encrypted_str.as_bytes()) + .map_err(|e| CoreError::DecryptionFailed(e.to_string()))?; + let decrypted_bytes = decrypt(&encrypted_bytes, encryption_key.as_bytes()) + .map_err(|e| CoreError::DecryptionFailed(e.to_string()))?; + String::from_utf8(decrypted_bytes) + .map_err(|e| CoreError::DecryptionFailed(e.to_string())) +} diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml new file mode 100644 index 000000000..102cd14ba --- /dev/null +++ b/crates/email/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "email" +edition = "2021" +version.workspace = true +rust-version.workspace = true + + +[dependencies] +handlebars = "5.1.0" +lettre = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +specta = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +utoipa = { version = "3.5.0" } diff --git a/crates/email/src/emailer.rs b/crates/email/src/emailer.rs new file mode 100644 index 000000000..1f8e30450 --- /dev/null +++ b/crates/email/src/emailer.rs @@ -0,0 +1,250 @@ +use std::path::PathBuf; + +use lettre::{ + address::AddressError, + message::{ + header::{self, ContentType}, + Attachment, MultiPart, SinglePart, + }, + transport::smtp::authentication::Credentials, + Message, SmtpTransport, Transport, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use specta::Type; +use utoipa::ToSchema; + +use crate::{render_template, EmailError, EmailResult, EmailTemplate}; + +/// The configuration for an [EmailerClient] +#[derive(Serialize, Deserialize, ToSchema, Type)] +pub struct EmailerClientConfig { + /// The email address to send from + pub sender_email: String, + /// The display name to use for the sender + pub sender_display_name: String, + /// The username to use for the SMTP server, typically the same as the sender email + pub username: String, + /// The plaintext password to use for the SMTP server, which will be encrypted before being stored + pub password: String, + /// The SMTP host to use + pub host: String, + /// The SMTP port to use + pub port: u16, + /// Whether to use TLS for the SMTP connection + pub tls_enabled: bool, + /// The maximum size of an attachment in bytes + pub max_attachment_size_bytes: Option, + /// The maximum number of attachments that can be sent in a single email + pub max_num_attachments: Option, +} + +/// Information about an attachment to be sent in an email, including the actual content +#[derive(Debug)] +pub struct AttachmentPayload { + /// The name of the attachment + pub name: String, + /// The bytes of the attachment + pub content: Vec, + /// The content type of the attachment, e.g. "text/plain" + pub content_type: ContentType, +} + +/// A client for sending emails +pub struct EmailerClient { + /// The configuration for the email client + config: EmailerClientConfig, + /// The directory where email templates are stored + template_dir: PathBuf, +} + +impl EmailerClient { + /// Create a new [EmailerClient] instance with the given configuration and template directory. + /// + /// # Example + /// ```rust + /// use email::{EmailerClient, EmailerClientConfig}; + /// use std::path::PathBuf; + /// + /// let config = EmailerClientConfig { + /// sender_email: "aaron@stumpapp.dev".to_string(), + /// sender_display_name: "Aaron's Stump Instance".to_string(), + /// username: "aaron@stumpapp.dev".to_string(), + /// password: "decrypted_password".to_string(), + /// host: "smtp.stumpapp.dev".to_string(), + /// port: 587, + /// tls_enabled: true, + /// max_attachment_size_bytes: Some(10_000_000), + /// max_num_attachments: Some(5), + /// }; + /// let template_dir = PathBuf::from("/templates"); + /// let emailer = EmailerClient::new(config, template_dir); + /// ``` + pub fn new(config: EmailerClientConfig, template_dir: PathBuf) -> Self { + Self { + config, + template_dir, + } + } + + /// Send an email with the given subject and attachment to the given recipient. + /// Internally, this will just call [EmailerClient::send_attachments] with a single attachment. + /// + /// # Example + /// ```rust + /// use email::{AttachmentPayload, EmailerClient, EmailerClientConfig}; + /// use std::path::PathBuf; + /// use lettre::message::header::ContentType; + /// + /// async fn test() { + /// let config = EmailerClientConfig { + /// sender_email: "aaron@stumpapp.dev".to_string(), + /// sender_display_name: "Aaron's Stump Instance".to_string(), + /// username: "aaron@stumpapp.dev".to_string(), + /// password: "decrypted_password".to_string(), + /// host: "smtp.stumpapp.dev".to_string(), + /// port: 587, + /// tls_enabled: true, + /// max_attachment_size_bytes: Some(10_000_000), + /// max_num_attachments: Some(5), + /// }; + /// let template_dir = PathBuf::from("/templates"); + /// let emailer = EmailerClient::new(config, template_dir); + /// + /// let result = emailer.send_attachment( + /// "Attachment Test", + /// "aaron@stumpapp.dev", + /// AttachmentPayload { + /// name: "test.txt".to_string(), + /// content: b"Hello, world!".to_vec(), + /// content_type: "text/plain".parse().unwrap(), + /// }, + /// ).await; + /// assert!(result.is_err()); // This will fail because the SMTP server is not real + /// } + /// ``` + pub async fn send_attachment( + &self, + subject: &str, + recipient: &str, + payload: AttachmentPayload, + ) -> EmailResult<()> { + self.send_attachments(subject, recipient, vec![payload]) + .await + } + + /// Send an email with the given subject and attachments to the given recipient. + /// The attachments are sent as a multipart email, with the first attachment being the email body. + /// + /// # Example + /// ```rust + /// use email::{AttachmentPayload, EmailerClient, EmailerClientConfig}; + /// use std::path::PathBuf; + /// use lettre::message::header::ContentType; + /// + /// async fn test() { + /// let config = EmailerClientConfig { + /// sender_email: "aaron@stumpapp.dev".to_string(), + /// sender_display_name: "Aaron's Stump Instance".to_string(), + /// username: "aaron@stumpapp.dev".to_string(), + /// password: "decrypted_password".to_string(), + /// host: "smtp.stumpapp.dev".to_string(), + /// port: 587, + /// tls_enabled: true, + /// max_attachment_size_bytes: Some(10_000_000), + /// max_num_attachments: Some(5), + /// }; + /// let template_dir = PathBuf::from("/templates"); + /// let emailer = EmailerClient::new(config, template_dir); + /// + /// let result = emailer.send_attachments( + /// "Attachment Test", + /// "aaron@stumpapp.dev", + /// vec![ + /// AttachmentPayload { + /// name: "test.txt".to_string(), + /// content: b"Hello, world!".to_vec(), + /// content_type: "text/plain".parse().unwrap(), + /// }, + /// AttachmentPayload { + /// name: "test2.txt".to_string(), + /// content: b"Hello, world again!".to_vec(), + /// content_type: "text/plain".parse().unwrap(), + /// }, + /// ], + /// ).await; + /// assert!(result.is_err()); // This will fail because the SMTP server is not real + /// } + /// ``` + pub async fn send_attachments( + &self, + subject: &str, + recipient: &str, + payloads: Vec, + ) -> EmailResult<()> { + let from = self + .config + .sender_email + .parse() + .map_err(|e: AddressError| EmailError::InvalidEmail(e.to_string()))?; + + let to = recipient + .parse() + .map_err(|e: AddressError| EmailError::InvalidEmail(e.to_string()))?; + + let html = render_template( + EmailTemplate::Attachment, + &json!({ + "title": "Stump Attachment", + }), + self.template_dir.clone(), + )?; + + let mut multipart_builder = MultiPart::mixed().singlepart( + SinglePart::builder() + .header(header::ContentType::TEXT_HTML) + .body(html), + ); + + for payload in payloads { + let attachment = + Attachment::new(payload.name).body(payload.content, payload.content_type); + multipart_builder = multipart_builder.singlepart(attachment); + } + + let email = Message::builder() + .from(from) + .to(to) + .subject(subject) + .multipart(multipart_builder)?; + + let creds = + Credentials::new(self.config.username.clone(), self.config.password.clone()); + + // Note this issue: https://github.com/lettre/lettre/issues/359 + let transport = if self.config.tls_enabled { + SmtpTransport::starttls_relay(&self.config.host) + .unwrap() + .credentials(creds) + .build() + } else { + SmtpTransport::relay(&self.config.host)? + .port(self.config.port) + .credentials(creds) + .build() + }; + + match transport.send(&email) { + Ok(res) => { + tracing::trace!(?res, "Email with attachments was sent"); + Ok(()) + }, + Err(e) => { + tracing::error!(error = ?e, "Failed to send email with attachments"); + Err(e.into()) + }, + } + } +} + +// TODO: write meaningful tests diff --git a/crates/email/src/error.rs b/crates/email/src/error.rs new file mode 100644 index 000000000..6c92865d4 --- /dev/null +++ b/crates/email/src/error.rs @@ -0,0 +1,21 @@ +use lettre::transport::smtp; + +pub type EmailResult = Result; + +/// An error type that represents what can go wrong when sending an email +/// using the `email` crate. +#[derive(Debug, thiserror::Error)] +pub enum EmailError { + #[error("Invalid email: {0}")] + InvalidEmail(String), + #[error("Failed to build email: {0}")] + EmailBuildFailed(#[from] lettre::error::Error), + #[error("Failed to send email: {0}")] + SendFailed(#[from] smtp::Error), + #[error("Failed to register template: {0}")] + TemplateRegistrationFailed(#[from] handlebars::TemplateError), + #[error("Template not found")] + TempalateNotFound, + #[error("Failed to render template: {0}")] + TemplateRenderFailed(#[from] handlebars::RenderError), +} diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs new file mode 100644 index 000000000..bdfa38eef --- /dev/null +++ b/crates/email/src/lib.rs @@ -0,0 +1,15 @@ +//! Email module for sending emails using SMTP. This module uses the `lettre` crate to send emails, +//! and the `handlebars` crate to render email templates. + +/// A module containing the emailer client and its configuration, as well as the sending of emails +mod emailer; +/// A module containing the error type for this crate +mod error; +/// A module containing the template rendering functionality, via the `handlebars` crate +mod template; + +pub use emailer::{AttachmentPayload, EmailerClient, EmailerClientConfig}; +pub use error::{EmailError, EmailResult}; +pub use template::{render_template, EmailTemplate}; + +pub use lettre::message::header::ContentType as EmailContentType; diff --git a/crates/email/src/template.rs b/crates/email/src/template.rs new file mode 100644 index 000000000..a9d0c77e2 --- /dev/null +++ b/crates/email/src/template.rs @@ -0,0 +1,74 @@ +use std::path::PathBuf; + +use crate::EmailResult; +use handlebars::Handlebars; + +// TODO: expose this enumeration to the public API somehow, so that users can define their own template overrides + +pub enum EmailTemplate { + /// A template for an email which includes attachment(s), e.g. a book on the server + Attachment, +} + +impl AsRef for EmailTemplate { + fn as_ref(&self) -> &str { + match self { + Self::Attachment => "attachment", + } + } +} + +/// Render a template to a string using the given data and templates directory. +/// +/// # Example +/// ```rust +/// use email::{render_template, EmailTemplate}; +/// use serde_json::json; +/// use std::path::PathBuf; +/// +/// let data = json!({ +/// "title": "Stump Attachment", +/// }); +/// +/// let rendered = render_template(EmailTemplate::Attachment, &data, PathBuf::from("templates")).unwrap(); +/// assert!(rendered.contains("Stump Attachment")); +/// ``` +pub fn render_template( + template: EmailTemplate, + data: &serde_json::Value, + templates_dir: PathBuf, +) -> EmailResult { + let mut handlebars = Handlebars::new(); + handlebars.register_partial("base_partial", "{{> base}}")?; + handlebars.register_template_file("base", templates_dir.join("base.hbs"))?; + handlebars + .register_template_file("attachment", templates_dir.join("attachment.hbs"))?; + + Ok(handlebars.render(template.as_ref(), data)?) +} + +// TODO: Write meaningful tests + +#[cfg(test)] +mod tests { + use super::*; + + fn default_templates_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates") + } + + #[test] + fn render_template_attachment() { + let data = serde_json::json!({ + "title": "Stump Attachment", + }); + + let rendered = + render_template(EmailTemplate::Attachment, &data, default_templates_dir()) + .unwrap(); + + dbg!(&rendered); + + assert!(rendered.contains("Stump Attachment")); + } +} diff --git a/crates/email/templates/attachment.hbs b/crates/email/templates/attachment.hbs new file mode 100644 index 000000000..044ba11a1 --- /dev/null +++ b/crates/email/templates/attachment.hbs @@ -0,0 +1,7 @@ +{{#*inline "page"}} +{{!-- TODO: design email --}} +

+ You have a new attachment from Stump! +

+{{/inline}} +{{> base}} \ No newline at end of file diff --git a/crates/email/templates/base.hbs b/crates/email/templates/base.hbs new file mode 100644 index 000000000..396cb1ad4 --- /dev/null +++ b/crates/email/templates/base.hbs @@ -0,0 +1,7 @@ +{{!-- TODO: design base email --}} + + {{title}} + + {{> page}} + + \ No newline at end of file diff --git a/crates/integrations/Cargo.toml b/crates/integrations/Cargo.toml index 0fd391a2d..3d7285e2e 100644 --- a/crates/integrations/Cargo.toml +++ b/crates/integrations/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] async-trait = { workspace = true } +lettre = { workspace = true } reqwest = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/packages/api/src/emailer.ts b/packages/api/src/emailer.ts new file mode 100644 index 000000000..b89eea9b7 --- /dev/null +++ b/packages/api/src/emailer.ts @@ -0,0 +1,122 @@ +import { + CreateOrUpdateEmailDevice, + CreateOrUpdateEmailer, + EmailerSendRecord, + PatchEmailDevice, + RegisteredEmailDevice, + SendAttachmentEmailResponse, + SendAttachmentEmailsPayload, + SMTPEmailer, +} from '@stump/types' + +import { API } from './axios' +import { APIResult } from './types' +import { toUrlParams } from './utils' + +function getEmailers(params?: Record): Promise> { + if (params) { + return API.get(`/emailers?${toUrlParams(params)}`) + } else { + return API.get('/emailers') + } +} + +function getEmailerById(id: number): Promise> { + return API.get(`/emailers/${id}`) +} + +function createEmailer(payload: CreateOrUpdateEmailer): Promise> { + return API.post('/emailers', payload) +} + +function updateEmailer( + id: number, + payload: CreateOrUpdateEmailer, +): Promise> { + return API.put(`/emailers/${id}`, payload) +} + +function deleteEmailer(id: number): Promise> { + return API.delete(`/emailers/${id}`) +} + +function getEmailDevices(): Promise> { + return API.get('/email-devices') +} + +function getEmailDeviceById(id: number): Promise> { + return API.get(`/email-devices/${id}`) +} + +function getEmailerSendHistory( + emailerId: number, + params?: Record, +): Promise> { + if (params) { + return API.get(`/emailers/${emailerId}/send-history?${toUrlParams(params)}`) + } else { + return API.get(`/emailers/${emailerId}/send-history`) + } +} + +function createEmailDevice( + payload: CreateOrUpdateEmailDevice, +): Promise> { + return API.post('/email-devices', payload) +} + +function updateEmailDevice( + id: number, + payload: CreateOrUpdateEmailDevice, +): Promise> { + return API.put(`/email-devices/${id}`, payload) +} + +function patchEmailDevice( + id: number, + payload: PatchEmailDevice, +): Promise> { + return API.patch(`/email-devices/${id}`, payload) +} + +function deleteEmailDevice(id: number): Promise> { + return API.delete(`/email-devices/${id}`) +} + +function sendAttachmentEmail( + payload: SendAttachmentEmailsPayload, +): Promise> { + return API.post('/emailers/send-attachment', payload) +} + +export const emailerApi = { + createEmailDevice, + createEmailer, + deleteEmailDevice, + deleteEmailer, + getEmailDeviceById, + getEmailDevices, + getEmailerById, + getEmailerSendHistory, + getEmailers, + patchEmailDevice, + sendAttachmentEmail, + updateEmailDevice, + updateEmailer, +} + +export const emailerQueryKeys: Record = { + createEmailDevice: 'emailDevice.create', + createEmailer: 'emailer.create', + deleteEmailDevice: 'emailDevice.delete', + deleteEmailer: 'emailer.delete', + getEmailDeviceById: 'emailDevice.getById', + getEmailDevices: 'emailDevices.get', + getEmailerById: 'emailer.getById', + getEmailerSendHistory: 'emailer.sendHistory', + getEmailers: 'emailer.get', + patchEmailDevice: 'emailDevice.patch', + sendAttachmentEmail: 'emailer.sendAttachment', + updateEmailDevice: 'emailDevice.update', + updateEmailer: 'emailer.update', +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d97a9a4bc..f67ae63cf 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,6 +1,7 @@ export { authApi, authQueryKeys } from './auth' export { API, apiIsInitialized, checkUrl, initializeApi, isUrl } from './axios' export { bookClubApi, bookClubQueryKeys } from './bookClub' +export { emailerApi, emailerQueryKeys } from './emailer' export { epubApi, epubQueryKeys, getEpubResource, updateEpubProgress } from './epub' export { filesystemApi, filesystemQueryKeys } from './filesystem' export * from './job' diff --git a/packages/browser/src/components/GenericEmptyState.tsx b/packages/browser/src/components/GenericEmptyState.tsx index c551956aa..41dcbcd51 100644 --- a/packages/browser/src/components/GenericEmptyState.tsx +++ b/packages/browser/src/components/GenericEmptyState.tsx @@ -7,22 +7,25 @@ type Props = { subtitle?: string containerClassName?: string contentClassName?: string + leftAlign?: boolean } export default function GenericEmptyState({ title, subtitle, containerClassName, contentClassName, + leftAlign, }: Props) { return (
-
+
{title} {subtitle && ( diff --git a/packages/browser/src/components/table/Pagination.tsx b/packages/browser/src/components/table/Pagination.tsx index 5870d92af..764412b59 100644 --- a/packages/browser/src/components/table/Pagination.tsx +++ b/packages/browser/src/components/table/Pagination.tsx @@ -37,7 +37,7 @@ export default function TablePagination({ return (
onChangePage(currentPage - 1)}> - + {pageRange.map((page, i) => { @@ -61,7 +61,7 @@ export default function TablePagination({ onPageChange={onChangePage} trigger={ } /> @@ -69,7 +69,7 @@ export default function TablePagination({ })} = pages} onClick={() => onChangePage(currentPage + 1)}> - +
) diff --git a/packages/browser/src/components/table/SortIcon.tsx b/packages/browser/src/components/table/SortIcon.tsx index 9ff09c174..a1e9bf677 100644 --- a/packages/browser/src/components/table/SortIcon.tsx +++ b/packages/browser/src/components/table/SortIcon.tsx @@ -1,11 +1,15 @@ import { cn } from '@stump/components' import { ArrowDown, ArrowUpDown } from 'lucide-react' -export default function SortIcon({ direction }: { direction: 'asc' | 'desc' | null }) { +type Props = { + direction: 'asc' | 'desc' | null + showIfNull?: boolean +} +export default function SortIcon({ direction, showIfNull }: Props) { const classes = 'h-3.5 w-3.5 text-muted shrink-0' if (!direction) { - return + return showIfNull ? : null } return ( diff --git a/packages/browser/src/components/table/Table.tsx b/packages/browser/src/components/table/Table.tsx index de0e8f083..48721fe4a 100644 --- a/packages/browser/src/components/table/Table.tsx +++ b/packages/browser/src/components/table/Table.tsx @@ -4,6 +4,7 @@ import { ColumnFiltersState, flexRender, getCoreRowModel, + getExpandedRowModel, getFilteredRowModel, getSortedRowModel, SortDirection, @@ -229,7 +230,26 @@ function SortIcon({ direction }: { direction: 'asc' | 'desc' | null }) { return ( - {direction === 'asc' ? : } + {direction === 'asc' ? ( + + ) : ( + + )} ) } + +export const getTableModels = ({ + filtered, + expanded, + sorted, +}: { + filtered?: boolean + expanded?: boolean + sorted?: boolean +}) => ({ + getCoreRowModel: getCoreRowModel(), + ...(filtered ? { getFilteredRowModel: getFilteredRowModel() } : {}), + ...(expanded ? { getExpandedRowModel: getExpandedRowModel(), getRowCanExpand: () => true } : {}), + ...(sorted ? { getSortedRowModel: getSortedRowModel() } : {}), +}) diff --git a/packages/browser/src/components/table/index.ts b/packages/browser/src/components/table/index.ts index 4ad7ac83a..cd412f46f 100644 --- a/packages/browser/src/components/table/index.ts +++ b/packages/browser/src/components/table/index.ts @@ -1,2 +1,2 @@ export { default as SortIcon } from './SortIcon' -export { default as Table } from './Table' +export { getTableModels, default as Table } from './Table' diff --git a/packages/browser/src/i18n/locales/fr.json b/packages/browser/src/i18n/locales/fr.json deleted file mode 100644 index bfdc582c4..000000000 --- a/packages/browser/src/i18n/locales/fr.json +++ /dev/null @@ -1,724 +0,0 @@ -{ - "authScene": { - "claimHeading": "Initialisez votre serveur", - "claimText": "Ce serveur Stump n'est pas initialisé. Utilisez le formulaire ci-dessous pour créer votre compte. Une fois créé, vous aurez un accès complet à toutes les fonctionnalités du serveur.", - "form": { - "validation": { - "missingUsername": "Le nom d'utilisateur est requis", - "missingPassword": "Le mot de passe est requis" - }, - "labels": { - "username": "Nom d'utilisateur", - "password": "Mot de passe" - }, - "buttons": { - "createAccount": "Créer un compte", - "login": "S'identifier" - } - }, - "toasts": { - "loggingIn": "Connexion en cours...", - "loggedIn": "Nous sommes heureux de vous revoir !", - "loggedInFirstTime": "Bienvenue !", - "registering": "Enregistrement en cours...", - "registered": "Enregistré !", - "loginFailed": "Échec de la connexion. Veuillez réessayer", - "registrationFailed": "Échec de l'inscription. Veuillez réessayer" - } - }, - "adminLibrarySidebar": { - "libraryConfiguration": { - "heading": "Configuration de la bibliothèque", - "subtitleCreating": "Un aperçu de votre nouvelle bibliothèque sera affiché ci-dessous", - "subtitleEditing": "Un aperçu des modifications de votre bibliothèque sera affiché ci-dessous" - } - }, - "bookOverviewScene": { - "fileInformation": { - "heading": "Informations sur le fichier", - "labels": { - "fileSize": "Taille du fichier", - "fileType": "Type de fichier", - "fileLocation": "Emplacement du fichier", - "fileChecksum": "Somme de contrôle" - } - }, - "nextInSeries": "Suivant dans la série" - }, - "createBookClubScene": { - "heading": "Créer un nouveau Club de Lecture", - "subtitle": "Vous pouvez créer un club de lecture privé en sélectionnant des membres, ou le rendre public pour tous sur le serveur", - "form": { - "name": { - "label": "Nom", - "placeholder": "Mon club de lecture", - "description": "Le nom de votre club de lecture" - }, - "description": { - "label": "Description", - "placeholder": "Un club de fans sur \"Notre Drapeau signifie la Mort\". Nous lisons des fictions sur la piraterie au gré de nos envies", - "description": "Une courte description facultative de votre club de lecture" - }, - "is_private": { - "label": "Club privé", - "description": "Si activée, seuls les utilisateurs que vous invitez pourront rejoindre votre club de lecture" - }, - "member_role_spec": { - "heading": "Mappage des rôles personnalisé", - "subtitle": [ - "You can create custom names for the roles in your book club. For example, you could rename the 'Member' role to 'Crewmate', or 'Creator' to 'Captain'. If you don't want to use custom names, you can leave these fields blank and the default names will be used instead. For more information about roles, refer to the", - "documentation" - ], - "member": { - "label": "Membre", - "placeholder": "Membre", - "description": "Le nom du rôle par défaut pour votre club de lecture" - }, - "moderator": { - "label": "Modérateur", - "placeholder": "Modérateur", - "description": "Le nom du rôle de modérateur pour votre club de lecture" - }, - "admin": { - "label": "Administrateur", - "placeholder": "Administrateur", - "description": "Le nom du rôle d'administrateur de votre club de lecture" - }, - "creator": { - "label": "Créateur", - "placeholder": "Créateur", - "description": "Le nom du rôle de créateur de votre club de lecture. C'est vous !" - } - }, - "creator_preferences": { - "heading": "Your membership preferences", - "subtitle": "Some preferences for your membership in the book club. These can be changed at any time from the book club settings page", - "creator_display_name": { - "label": "Nom affiché", - "placeholder": "oromei", - "description": "Un nom affiché facultatif pour votre adhésion au club de lecture. Si défini, celui-ci prend la priorité sur votre nom d'utilisateur" - }, - "creator_hide_progress": { - "label": "Masquer la progression", - "description": "Si activée, votre progression de lecture sera cachée aux autres membres du club" - } - }, - "submit": "Créer un Club de Lecture" - } - }, - "createLibraryScene": { - "heading": "Créer une nouvelle bibliothèque", - "subtitle": "Les bibliothèques sont utilisées pour regrouper vos livres. Si vous souhaitez en savoir plus sur les bibliothèques et leur fonctionnement, consultez la", - "subtitleLink": "documentation correspondante", - "form": { - "labels": { - "libraryName": "Nom", - "libraryPath": "Chemin d'accès", - "libraryPathDescription": "Le chemin d'accès à la bibliothèque tel qu'il existe sur votre serveur", - "libraryDescription": "Description", - "libraryTags": "Étiquettes" - }, - "buttons": { - "confirm": "Créer la bibliothèque" - } - } - }, - "librarySettingsScene": { - "heading": "Gérer la Bibliothèque", - "subtitle": "Mettez à jour les détails ou la configuration de votre bibliothèque, modifiez les règles d'accès ou scannez des fichiers. Si vous voulez une mise à jour sur les bibliothèques et comment elles fonctionnent, consultez la", - "subtitleLink": "documentation correspondante", - "form": { - "labels": { - "libraryName": "Nom", - "libraryPath": "Chemin d'accès", - "libraryPathDescription": "Le chemin d'accès à la bibliothèque tel qu'il existe sur votre serveur", - "libraryDescription": "Description", - "libraryTags": "Étiquettes" - }, - "buttons": { - "confirm": "Enregistrer les modifications" - } - } - }, - "errorScene": { - "heading": "lol, oups", - "message": "Une erreur s'est produite :", - "buttons": { - "report": "Signaler un bug", - "copy": "Copier les détails de l'erreur", - "goHome": "Aller à la page d'accueil" - } - }, - "homeScene": { - "continueReading": { - "title": "Continuer la lecture", - "emptyState": { - "heading": "Aucun livre à afficher", - "message": "Tous les livres que vous lisez actuellement apparaîtront ici" - } - }, - "recentlyAddedSeries": { - "title": "Séries récemment ajoutées", - "emptyState": { - "heading": "Aucune série à afficher", - "message": "Toutes les séries que vous ajoutez à vos bibliothèques apparaîtront ici" - } - }, - "recentlyAddedBooks": { - "title": "Livres récemment ajoutés", - "emptyState": { - "heading": "Aucun livre à afficher", - "message": "Tous les livres que vous ajoutez à vos bibliothèques apparaîtront ici" - } - } - }, - "seriesOverviewScene": { - "buttons": { - "continueReading": "Continuer la lecture", - "downloadSeries": "Télécharger la série" - } - }, - "userSmartListsScene": { - "heading": "Listes intelligentes", - "subtitle": "Vos recherches et filtres favoris sauvegardés pour un accès facile", - "searchPlaceholder": "Filtrer les listes intelligentes", - "buttons": { - "createSmartList": "Créer une liste intelligente" - }, - "list": { - "emptyState": { - "heading": "Aucune liste intelligente à afficher", - "noListsMessage": "Créez une liste intelligente pour commencer", - "noMatchesMessage": "Essayez de modifier votre recherche" - }, - "card": { - "meta": { - "figures": { - "books": "livre", - "series": "séries", - "library": "bibliothèque" - }, - "matches": "Correspondances" - } - } - } - }, - "userSmartListScene": { - "navigation": { - "items": "Éléments", - "settings": "Paramètres" - }, - "layout": { - "missingIdError": "Cette scène nécessite un ID dans l'URL", - "smartListNotFound": "La liste intelligente est introuvable", - "viewCreateError": "Échec lors de la création de l'affichage", - "viewSaveError": "Échec lors de la sauvegarde de l'affichage" - }, - "header": { - "backLink": "Listes" - }, - "itemsScene": { - "smartListNotFound": "La liste intelligente est introuvable", - "actionHeader": { - "viewSelector": { - "customView": "Affichage personnalisé", - "defaultView": "Affichage par défaut", - "noViewsSaved": "Aucun affichage enregistré", - "selectView": "Sélectionnez un affichage enregistré" - }, - "filterDrawer": { - "heading": "Filtres de liste intelligente", - "description": "Changer les filtres pour cette session affichée", - "rawData": { - "heading": "Données du filtre brut", - "description": "Voici comment Stump traitera les filtres" - }, - "buttons": { - "save": "Sauvegarder", - "cancel": "Annuler" - } - }, - "search": { - "placeholder": "Filtre rapide" - }, - "viewManager": { - "updateSelected": "Mettre à jour la sélection", - "create": "Créer un nouvel affichage", - "modal": { - "heading": { - "create": "Créer un affichage", - "update": "Actualiser l'affichage" - }, - "description": { - "create": "Créer un nouvel affichage pour cette liste intelligente", - "update": "Actualiser l'affichage" - }, - "createForm": { - "name": { - "label": "Nom", - "placeholder": "Mon affichage", - "description": "Un nom sympa pour identifier de manière unique cet affichage" - } - }, - "updateForm": { - "name": { - "label": "Nom", - "placeholder": "Mon affichage", - "description": "Le nom actualisé pour cet affichage, si désiré" - } - } - } - } - } - } - }, - "settingsScene": { - "navigation": { - "general": "Général", - "logs": "Journaux", - "server": "Serveur", - "jobs": "Tâches et Configuration", - "users": "Gestion des Utilisateurs", - "desktop": "Bureau" - }, - "sidebar": { - "application": { - "account": "Compte", - "appearance": "Apparence", - "reader": "Lecteur", - "desktop": "Bureau", - "label": "Application" - }, - "server": { - "general": "Général", - "logs": "Journaux", - "users": "Utilisateurs", - "jobs": "Tâches", - "access": "Accéder", - "notifications": "Notifications", - "label": "Serveur" - } - }, - "app/account": { - "helmet": "Paramètres du compte", - "title": "Paramètres du compte", - "description": "Paramètres liés à votre compte", - "sections": { - "account": { - "validation": { - "invalidUrl": "Veuillez saisir une URL valide", - "missingUsername": "Nom d’utilisateur requis" - }, - "labels": { - "username": "Nom d'utilisateur", - "password": "Mot de passe", - "activeChangesPrompt": "Vous avez des modifications non enregistrées" - }, - "errors": { - "updateFailed": "Une erreur de serveur est survenue lors de la mise à jour de votre profil. Veuillez réessayer." - }, - "avatarPicker": { - "heading": "Définir votre image d'avatar", - "subtitle": "Stump prend en charge les avatars personnalisés, qui peuvent être définis en fournissant une URL vers une image. Les téléchargements d'images ne sont pas pris en charge pour réduire la quantité de données stockées sur le serveur.", - "preview": "Un aperçu apparaîtra ici une fois que vous aurez entré une URL.", - "labels": { - "imageUrl": "URL de l'image", - "customAvatar": "Personnaliser l’avatar" - }, - "buttons": { - "confirm": "Confirmer l'image", - "cancel": "Annuler", - "edit": "Éditer", - "changeImage": "Changer l'image", - "removeImage": "Supprimer l'image" - } - }, - "buttons": { - "confirm": "Enregistrer les modifications" - } - }, - "locale": { - "localeSelector": { - "label": "Langue" - }, - "heading": "Langue", - "subtitle": [ - "Stump prend en charge plusieurs langues, définissez vos préférences ci-dessous. Pensez à aider à améliorer la qualité des", - "traductions de Stump", - "si vous le pouvez !" - ] - } - } - }, - "app/appearance": { - "helmet": "Apparence", - "title": "Apparence", - "description": "Personnaliser l'apparence de l'application", - "sections": { - "themeSelect": { - "label": "Thème", - "description": "Par défaut, Stump possède un thème clair et sombre", - "customTheme": [ - "Si vous êtes intéressé par la création de votre propre thème personnalisé, consultez la", - "documentation" - ], - "options": { - "light": "Clair", - "dark": "Sombre", - "bronze": "Bronze léger" - } - } - } - }, - "app/reader": { - "helmet": "Paramètres du lecteur", - "title": "Paramètres du lecteur", - "description": "Options par défaut pour les lecteurs. Elles sont liées à votre appareil actuel uniquement", - "sections": { - "imageBasedBooks": { - "label": "Livres illustrés", - "description": "Bandes dessinées, mangas, et autres livres illustrés", - "sections": { - "preloadAheadCount": { - "label": "Nombre de préchargements avant la page actuelle", - "description": "Le nombre de pages à précharger avant la page actuelle" - }, - "preloadBehindCount": { - "label": "Nombre de préchargements après la page actuelle", - "description": "Le nombre de pages à précharger après la page actuelle" - } - } - } - } - }, - "app/desktop": { - "helmet": "Paramètres du bureau", - "title": "Paramètres du bureau", - "description": "Paramètres liés à l'application de bureau Stump", - "sections": { - "discordPresence": { - "label": "Présence Discord", - "description": "Affiche votre activité Stump sur Discord en utilisant la Présence Riche de Discord", - "reconnect": "Se reconnecter à Discord" - } - } - }, - "server/general": { - "helmet": "Paramètres généraux du serveur", - "title": "Paramètres généraux", - "description": "Paramètres généraux liés à l'instance de votre serveur Stump", - "sections": { - "updateAvailable": { - "message": "Votre serveur n'est pas à jour. Veuillez le mettre à jour vers la dernière version !" - }, - "serverInfo": { - "title": "Informations du serveur", - "description": "Détails de base sur l'instance de votre serveur Stump", - "build": { - "label": "Version", - "description": "Détails à propos de la version", - "version": { - "semver": "Version", - "commitHash": "Commit exact", - "date": "Date de compilation" - } - } - } - } - }, - "server/logs": { - "helmet": "Journaux", - "title": "Journaux", - "description": "Les journaux générés par votre instance de serveur Stump", - "sections": { - "persistedLogs": { - "title": "Journaux persistants", - "description": "Ces journaux ont été enregistrés manuellement dans la base de données et sont généralement associés à un travail ou à un événement spécifique", - "table": { - "columns": { - "level": "Niveau", - "message": "Message", - "timestamp": "Horodatage" - }, - "emptyHeading": "Aucun journal à afficher", - "emptySubtitle": "Votre serveur est soit très sain, soit en très mauvais état" - } - }, - "liveLogs": { - "title": "Flux des journaux en direct", - "description": "Diffusion directe depuis votre instance de serveur Stump en temps réel" - } - } - }, - "server/jobs": { - "helmet": "Tâches", - "title": "Tâches", - "description": "Tâches en arrière-plan qui s'exécutent sur l'instance de votre serveur Stump", - "sections": { - "scheduling": { - "title": "Planification", - "description": "Certaines tâches peuvent être configurées pour être exécutées à un moment défini. Les modifications de cette configuration prendront effet après le redémarrage du serveur" - }, - "history": { - "title": "Historique", - "description": "Un enregistrement des tâches qui ont été exécutées sur l'instance de votre serveur Stump", - "table": { - "columns": { - "name": "Type", - "description": "Description", - "status": "État", - "createdAt": "Commencée à", - "elapsed": "Temps écoulé", - "tasks": "Tâches" - }, - "emptyHeading": "Il n'y a aucune tâche à afficher", - "emptySubtitle": "Vous ne pouvez pas avoir une tâche si vous n'avez pas une tâche définie", - "deleteAllMessage": "L'historique des tâches et les statistiques peuvent être supprimés de la base de données à tout moment. Cette action ne peut pas être annulée", - "deleteAllConfirmButton": "Effacer l'historique", - "deleteAllConfirmButtonTitle": "Effacer l'historique des tâches", - "deleteAllConfirmButtonTitleNoJobs": "Aucune tâche à effacer" - } - } - } - }, - "server/users": { - "helmet": "Gestion des utilisateurs", - "title": "Utilisateurs", - "description": "Gérer les utilisateurs sur ce serveur", - "createUser": { - "helmet": "Créer un utilisateur", - "title": "Créer un utilisateur", - "description": "Créer un nouvel utilisateur sur ce serveur" - }, - "updateUser": { - "helmet": "Modifier l'utilisateur", - "title": "Modifier l'utilisateur", - "description": "Mettre à jour les détails de cet utilisateur" - }, - "createOrUpdateForm": { - "accessControl": { - "heading": "Contrôle d'accès et restrictions", - "subtitle": [ - "Configurez toutes les restrictions que vous souhaitez appliquer à cet utilisateur. Pour plus d'informations sur le contrôle d'accès, consultez la", - "documentation" - ], - "ageRestriction": { - "label": "Restriction d'âge", - "description": "Empêche l'utilisateur d'accéder au contenu au-delà du seuil d'âge défini", - "placeholder": "13", - "enforceUnset": { - "label": "Appliquer en cas d'absence de paramétrage", - "description": "Si activée, les utilisateurs ne pourront pas accéder aux contenus pour lesquels aucune classification par âge n'est disponible" - } - }, - "tagRestriction": { - "label": "Restrictions d'étiquettes", - "description": "Empêche l'utilisateur d'accéder au contenu contenant les étiquettes sélectionnées", - "placeholder": "Adulte, Gore" - } - }, - "permissions": { - "heading": "Permissions", - "subtitle": [ - "Sélectionnez les autorisations que vous souhaitez accorder à cet utilisateur. Pour plus d'informations sur la fonction de chaque autorisation, consultez la", - "documentation" - ], - "bookclub": { - "label": "Clubs de lecture", - "read": { - "label": "Accéder aux fonctionnalités des clubs de lecture", - "description": "Permet à l'utilisateur d'accéder aux fonctionnalités des clubs de lecture, incluant la visualisation et l'inscription à des clubs de lecture" - }, - "create": { - "label": "Créer des Clubs de Lecture", - "description": "Permet à l'utilisateur de créer de nouveaux clubs de lectures" - } - }, - "file": { - "label": "Gestion des fichiers", - "explorer": { - "label": "File Explorer", - "description": "Allows the user to access the Library File Explorer.\nContent restriction is not supported when this feature is granted" - }, - "download": { - "label": "Télécharger les fichiers", - "description": "Permet à l'utilisateur de télécharger des fichiers à partir du serveur" - }, - "upload": { - "label": "Envoyer des fichiers", - "description": "Permet à l'utilisateur d'envoyer des fichiers sur le serveur" - } - }, - "library": { - "label": "Gestion des bibliothèques", - "create": { - "label": "Créer des bibliothèques", - "description": "Permet à l'utilisateur de créer de nouvelles bibliothèques.\nInclut les permissions d'édition et de scan" - }, - "scan": { - "label": "Scanner les bibliothèques", - "description": "Permet à l'utilisateur de lancer des scans pour les bibliothèques existantes" - }, - "edit": { - "label": "Modifier les bibliothèques", - "description": "Permet à l'utilisateur de modifier les détails de base des bibliothèques existantes" - }, - "manage": { - "label": "Gérer les bibliothèques", - "description": "Permet à l'utilisateur de gérer les paramètres avancés des bibliothèques existantes.\nInclut les permissions d'édition et de scan" - }, - "delete": { - "label": "Supprimer les bibliothèques", - "description": "Permet à l'utilisateur de supprimer des bibliothèques existantes.\nInclut les permissions d'édition, de gestion et de scan" - } - }, - "server": { - "label": "Gestion du serveur", - "manage": { - "label": "Gérer le serveur", - "description": "Permet à l'utilisateur de gérer le serveur.\nComprend *beaucoup* d'autres autorisations" - } - }, - "user": { - "label": "Gestion des Utilisateurs", - "read": { - "label": "Lire des utilisateurs", - "description": "Permet à l'utilisateur de rechercher d'autres utilisateurs sur le serveur. Cette fonction est nécessaire pour certaines fonctionnalités, par exemple pour réduire l'accès à une bibliothèque pour des utilisateurs" - }, - "manage": { - "label": "Gérer les utilisateurs", - "description": "Allows the user to manage other users on the server.\nIncludes permissions to create and update" - } - }, - "smartlist": { - "label": "Listes intelligentes", - "read": { - "label": "Accéder à la fonctionnalité Liste Intelligente", - "description": "Allows the user to access smart lists features" - } - } - }, - "validation": { - "ageRestrictionTooLow": "La restriction d'âge ne peut pas être inférieure à 0" - }, - "createSubmitButton": "Créer un utilisateur", - "updateSubmitButton": "Modifier l'utilisateur" - } - } - }, - "jobOverlay": { - "backupHeading": "Traitement en cours" - }, - "libraryStats": { - "seriesCount": "Total des séries", - "bookCount": "Total des livres", - "diskUsage": "Utilisation du disque" - }, - "pagination": { - "buttons": { - "next": "Suivant", - "previous": "Précédent" - }, - "popover": { - "heading": "Aller à la page", - "buttons": { - "confirm": "Aller", - "cancel": "Annuler" - } - } - }, - "signOutModal": { - "title": "Déconnexion", - "message": "Êtes-vous sûr(e) de vouloir vous déconnecter ?", - "buttons": { - "cancel": "Annuler", - "signOut": "Se déconnecter" - } - }, - "sidebar": { - "buttons": { - "home": "Accueil", - "libraries": "Bibliothèques", - "books": "Explorer", - "bookClubs": "Clubs de lecture", - "createLibrary": "Créer une bibliothèque", - "noLibraries": "Aucune bibliothèque", - "createBookClub": "Créer un Club de Lecture", - "noBookClubs": "Aucun club de lecture", - "settings": "Paramètres", - "themeToggle": "Changer de thème", - "goForward": "Aller en avant", - "goBack": "Aller en arrière", - "smartlists": "Listes intelligentes", - "noSmartlists": "Aucune liste intelligente", - "createSmartlist": "Créer une liste intelligente" - }, - "libraryOptions": { - "scanLibrary": "Scanner", - "fileExplorer": "Explorateur de fichiers", - "manageLibrary": "Manage", - "deleteLibrary": "Supprimer" - }, - "versionInformation": { - "heading": "Informations de version", - "semVer": "Version sémantique", - "commitHash": "Hachage de validation", - "buildDate": "Date de construction" - } - }, - "search": { - "placeholder": "Recherche" - }, - "serverSOS": { - "heading": "Serveur non disponible", - "desktop": { - "message": "Une erreur de réseau s'est produite indiquant que votre serveur Stump est actuellement indisponible. Veuillez vous assurer qu'il est en cours d'exécution et accessible depuis cet appareil.\nSi l'URL de votre serveur a changé, vous pouvez la mettre à jour à l'aide du formulaire ci-dessous" - }, - "web": { - "message": "Une erreur de réseau s'est produite indiquant que votre serveur Stump est actuellement indisponible. Veuillez vous assurer qu'il est en cours d'exécution et accessible depuis cet appareil" - }, - "reconnected": "Reconnecté au serveur! Redirection...", - "reconnectionFailed": "Quelque chose s'est mal passé!" - }, - "serverStatusOverlay": { - "heading": "Le serveur n'est pas connecté", - "message": [ - "Veuillez vérifier votre connexion internet", - "Cliquez ici", - "pour changer l'URL de votre serveur" - ] - }, - "slidingList": { - "empty": "Aucun élément à afficher", - "buttons": { - "next": "Avancer", - "previous": "Reculer" - } - }, - "tagSelect": { - "placholder": "Choisissez ou créez une étiquette", - "placeholderNoTags": "Aucune étiquette disponible" - }, - "thumbnailDropdown": { - "label": "Edit thumbnail", - "options": { - "selectFromBooks": "Sélectionner à partir des livres", - "uploadImage": "Importer une image" - }, - "uploadImage": { - "emptyState": "Un aperçu de votre image apparaîtra ici", - "prompt": "Déposez l'image ici ou cliquez pour sélectionner", - "remove": "Supprimer l’image" - } - }, - "common": { - "cancel": "Annuler", - "confirm": "Confirmer", - "save": "Sauvegarder", - "saveChanges": "Enregistrer les modifications", - "create": "Créer", - "edit": "Éditer", - "unimplemented": "Cette fonctionnalité n'est pas encore implémentée ! Revenez plus tard", - "limitedFunctionality": "Ceci n'est pas encore totalement implémenté et manque de certaines fonctionnalités. Revenez plus tard" - } -} \ No newline at end of file diff --git a/packages/browser/src/paths.ts b/packages/browser/src/paths.ts index 5994cc7b1..d59214b50 100644 --- a/packages/browser/src/paths.ts +++ b/packages/browser/src/paths.ts @@ -16,6 +16,8 @@ type SettingsPage = | 'server/logs' | 'server/users' | 'server/access' + | 'server/email' + | 'server/email/new' | 'server/notifications' type DocTopic = 'access-control' | 'book-club' type BookClubTab = 'overview' | 'members' | 'chat-board' | 'settings' @@ -78,8 +80,10 @@ const paths = { return `${baseUrl}/reader?${searchParams.toString()}` }, bookSearch: () => '/books', + createEmailer: () => paths.settings('server/email/new'), docs: (topic?: DocTopic, section?: string) => `https://www.stumpapp.dev/guides/${topic || ''}${section ? `#${section}` : ''}`, + editEmailer: (id: number) => paths.settings('server/email') + `/${id}/edit`, home: () => '/', libraryBooks: (id: string, page?: number) => { if (page !== undefined) { diff --git a/packages/browser/src/scenes/book/BookOverviewScene.tsx b/packages/browser/src/scenes/book/BookOverviewScene.tsx index a9db78487..e0adca604 100644 --- a/packages/browser/src/scenes/book/BookOverviewScene.tsx +++ b/packages/browser/src/scenes/book/BookOverviewScene.tsx @@ -21,6 +21,7 @@ import BookLibrarySeriesLinks from './BookLibrarySeriesLinks' import BookReaderDropdown from './BookReaderDropdown' import BooksAfterCursor from './BooksAfterCursor' import DownloadMediaButton from './DownloadMediaButton' +import EmailBookDropdown from './EmailBookDropdown' // TODO: redesign page? // TODO: with metadata being collected now, there is a lot more information to display: @@ -118,6 +119,7 @@ export default function BookOverviewScene() { )} {canDownload && } +
{!isAtLeastMedium && !!media.metadata?.summary && ( diff --git a/packages/browser/src/scenes/book/EmailBookDropdown.tsx b/packages/browser/src/scenes/book/EmailBookDropdown.tsx new file mode 100644 index 000000000..d1bf759eb --- /dev/null +++ b/packages/browser/src/scenes/book/EmailBookDropdown.tsx @@ -0,0 +1,169 @@ +import { useEmailDevicesQuery, useSendAttachmentEmail } from '@stump/client' +import { Badge, Button, ComboBox, Dialog, IconButton, Input } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { Send } from 'lucide-react' +import React, { Suspense, useCallback, useMemo, useState } from 'react' +import toast from 'react-hot-toast' + +import { useAppContext } from '@/context' + +type ContainerProps = { + mediaId: string +} +export default function EmailBookDropdownContainer({ mediaId }: ContainerProps) { + const { checkPermission } = useAppContext() + + const canSendEmail = useMemo(() => checkPermission('email:send'), [checkPermission]) + const canArbitrarySendEmail = useMemo( + () => checkPermission('email:arbitrary_send'), + [checkPermission], + ) + + if (!canSendEmail && !canArbitrarySendEmail) { + return null + } + + return ( + + + + ) +} + +type Props = { + canArbitrarySendEmail: boolean +} & ContainerProps + +function EmailBookDropdown({ mediaId, canArbitrarySendEmail }: Props) { + const { t } = useLocaleContext() + const { devices } = useEmailDevicesQuery() + const { sendAsync: sendEmail, isSending } = useSendAttachmentEmail() + + const [isOpen, setIsOpen] = useState(false) + const [deviceIds, setDeviceIds] = useState([]) + const [emails, setEmails] = useState([]) + + const [currentEmail, setCurrentEmail] = useState('') + + const handleSend = useCallback(async () => { + if (deviceIds.length === 0 && emails.length === 0) { + return + } + + const payload = { + media_ids: [mediaId], + send_to: [ + ...deviceIds.map((id) => ({ device_id: id })), + ...(canArbitrarySendEmail ? emails.map((email) => ({ email })) : []), + ], + } + + try { + const { errors } = await sendEmail(payload) + setIsOpen(errors.length > 0) + if (errors.length > 0) { + console.warn(errors) + toast.error('Some errors occurred while sending email(s). Check the logs for more detail') + } + } catch (error) { + console.error(error) + toast.error('Failed to send email') + } + }, [sendEmail, deviceIds, emails, canArbitrarySendEmail, mediaId]) + + const renderArbitraryEmails = () => { + if (!canArbitrarySendEmail) { + return null + } else { + return ( +
+
+ {emails.map((email, index) => ( + setEmails((curr) => curr.filter((e) => e !== email))} + > + {email} + + ))} +
+ +
+ setCurrentEmail(e.target.value)} + /> + +
+
+ ) + } + } + + return ( + + + + + + + + + {t(getKey('heading'))} + {t(getKey('description'))} + setIsOpen(false)} disabled={isSending} /> + + +
+ ({ + label: device.name, + value: device.id.toString(), + }))} + isMultiSelect + filterable + filterEmptyMessage={t(getFormKey('devices.noFilterMatch'))} + value={deviceIds.map((id) => id.toString())} + onChange={(selected) => { + setDeviceIds(selected?.map((id) => parseInt(id)).filter((id) => !isNaN(id)) || []) + }} + size="full" + /> + + {renderArbitraryEmails()} +
+ + + + + +
+
+ ) +} + +const BASE_LOCALE_KEY = 'bookOverviewScene.emailBook' +const getKey = (key: string) => `${BASE_LOCALE_KEY}.${key}` +const getFormKey = (key: string) => `${BASE_LOCALE_KEY}.form.${key}` diff --git a/packages/browser/src/scenes/settings/SettingsHeader.tsx b/packages/browser/src/scenes/settings/SettingsHeader.tsx index af66980d8..b6338374b 100644 --- a/packages/browser/src/scenes/settings/SettingsHeader.tsx +++ b/packages/browser/src/scenes/settings/SettingsHeader.tsx @@ -1,4 +1,4 @@ -import { cx, Heading, Text } from '@stump/components' +import { cx, Heading, Link, Text } from '@stump/components' import { useLocaleContext } from '@stump/i18n' import React, { useMemo } from 'react' import { useLocation } from 'react-router' @@ -49,6 +49,18 @@ export default function SettingsHeader({ renderNavigation }: Props) { return matchedSubItemKey || activeRouteGroup?.localeKey }, [activeRouteGroup, location.pathname]) + const backlink = useMemo(() => { + const matchedSubItem = activeRouteGroup?.subItems?.find((subItem) => + subItem.matcher(location.pathname), + ) + + if (matchedSubItem?.backlink) { + return matchedSubItem.backlink + } else { + return null + } + }, [activeRouteGroup?.subItems, location.pathname]) + const translatedHeader = t(`settingsScene.${activeRouteKey}.title`) const descriptionKey = `settingsScene.${activeRouteKey}.description` @@ -64,7 +76,15 @@ export default function SettingsHeader({ renderNavigation }: Props) { style={{ maxWidth }} > {renderNavigation && } -
+
+ {backlink && ( + + + {t(`settingsScene.${backlink.localeKey}`) ?? 'Back'} + + {' /'} + + )} {translatedHeader} diff --git a/packages/browser/src/scenes/settings/routes.ts b/packages/browser/src/scenes/settings/routes.ts index 90b96a7c0..6290f9eb7 100644 --- a/packages/browser/src/scenes/settings/routes.ts +++ b/packages/browser/src/scenes/settings/routes.ts @@ -1,3 +1,4 @@ +import { UserPermission } from '@stump/types' import { AlarmClock, Bell, @@ -5,6 +6,7 @@ import { Brush, Cog, LucideIcon, + Mail, PcCase, ScrollText, ShieldCheck, @@ -14,13 +16,17 @@ import { type SubItem = { localeKey: string matcher: (path: string) => boolean + backlink?: { + localeKey: string + to: string + } } type Route = { icon: LucideIcon label: string localeKey: string - permission?: string + permission?: UserPermission to: string subItems?: SubItem[] disabled?: boolean @@ -95,10 +101,18 @@ export const routeGroups: RouteGroup[] = [ permission: 'user:manage', subItems: [ { + backlink: { + localeKey: 'server/users.title', + to: '/settings/server/users', + }, localeKey: 'server/users.createUser', matcher: (path: string) => path.startsWith('/settings/server/users/create'), }, { + backlink: { + localeKey: 'server/users.title', + to: '/settings/server/users', + }, localeKey: 'server/users.updateUser', matcher: (path: string) => { const match = path.match(/\/settings\/server\/users\/[a-zA-Z0-9]+\/manage/) @@ -116,6 +130,34 @@ export const routeGroups: RouteGroup[] = [ permission: 'server:manage', to: '/settings/server/access', }, + { + icon: Mail, + label: 'Email', + localeKey: 'server/email', + permission: 'emailer:read', + subItems: [ + { + backlink: { + localeKey: 'server/email.title', + to: '/settings/server/email', + }, + localeKey: 'server/email.createEmailer', + matcher: (path: string) => path.startsWith('/settings/server/email/new'), + }, + { + backlink: { + localeKey: 'server/email.title', + to: '/settings/server/email', + }, + localeKey: 'server/email.updateEmailer', + matcher: (path: string) => { + const match = path.match(/\/settings\/server\/email\/[0-9]+\/edit/) + return !!match && match.length > 0 + }, + }, + ], + to: '/settings/server/email', + }, { disabled: true, icon: Bell, diff --git a/packages/browser/src/scenes/settings/server/ServerSettingsRouter.tsx b/packages/browser/src/scenes/settings/server/ServerSettingsRouter.tsx index f824d62f1..593bd7027 100644 --- a/packages/browser/src/scenes/settings/server/ServerSettingsRouter.tsx +++ b/packages/browser/src/scenes/settings/server/ServerSettingsRouter.tsx @@ -3,6 +3,7 @@ import { Route, Routes, useNavigate } from 'react-router' import { useAppContext } from '@/context' +import { EmailSettingsRouter } from './email' import { UsersRouter } from './users' const GeneralServerSettingsScene = lazy(() => import('./general/GeneralServerSettingsScene.tsx')) @@ -16,8 +17,9 @@ export default function ServerSettingsRouter() { const canManageServer = useMemo(() => checkPermission('server:manage'), [checkPermission]) const canManageUsers = useMemo(() => checkPermission('user:manage'), [checkPermission]) + const canManageEmail = useMemo(() => checkPermission('emailer:manage'), [checkPermission]) - const hasAtLeastOnePermission = canManageServer || canManageUsers + const hasAtLeastOnePermission = canManageServer || canManageUsers || canManageEmail useEffect(() => { if (!hasAtLeastOnePermission) { navigate('/settings', { replace: true }) @@ -34,6 +36,7 @@ export default function ServerSettingsRouter() { {canManageServer && } />} {canManageServer && } />} {canManageUsers && } />} + {canManageEmail && } />} ) } diff --git a/packages/browser/src/scenes/settings/server/email/CreateEmailerScene.tsx b/packages/browser/src/scenes/settings/server/email/CreateEmailerScene.tsx new file mode 100644 index 000000000..cac943161 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/CreateEmailerScene.tsx @@ -0,0 +1,61 @@ +import { emailerApi } from '@stump/api' +import { useEmailersQuery, useMutation } from '@stump/client' +import React, { useEffect } from 'react' +import { useNavigate } from 'react-router' + +import { ContentContainer, SceneContainer } from '@/components/container' +import paths from '@/paths' + +import { useEmailerSettingsContext } from './context' +import { CreateOrUpdateEmailerForm, FormValues } from './emailers' + +export default function CreateEmailerScene() { + const navigate = useNavigate() + + const { canCreateEmailer } = useEmailerSettingsContext() + const { emailers } = useEmailersQuery({ + suspense: true, + }) + const { mutateAsync: createEmailer } = useMutation(['createEmailer'], emailerApi.createEmailer) + + const onSubmit = async ({ name, is_primary, ...config }: FormValues) => { + try { + await createEmailer({ + // @ts-expect-error: FIXME: fixme + config: { + ...config, + host: config.smtp_host, + max_num_attachments: null, + port: config.smtp_port, + }, + is_primary, + name, + }) + navigate(paths.settings('server/email')) + } catch (error) { + console.error(error) + // TODO:toast + } + } + + useEffect(() => { + if (!canCreateEmailer) { + navigate('..', { replace: true }) + } + }, [canCreateEmailer, navigate]) + + if (!canCreateEmailer) { + return null + } + + return ( + + + e.name) || []} + onSubmit={onSubmit} + /> + + + ) +} diff --git a/packages/browser/src/scenes/settings/server/email/EditEmailerScene.tsx b/packages/browser/src/scenes/settings/server/email/EditEmailerScene.tsx new file mode 100644 index 000000000..93ed30f6d --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/EditEmailerScene.tsx @@ -0,0 +1,70 @@ +import { useEmailerQuery, useEmailersQuery, useUpdateEmailer } from '@stump/client' +import React, { useEffect, useMemo } from 'react' +import { useNavigate, useParams } from 'react-router' + +import { ContentContainer, SceneContainer } from '@/components/container' +import paths from '@/paths' + +import { useEmailerSettingsContext } from './context' +import { CreateOrUpdateEmailerForm, FormValues } from './emailers' + +export default function EditEmailerScene() { + const navigate = useNavigate() + + const { id: rawId } = useParams<{ id: string }>() + const id = useMemo(() => parseInt(rawId || '', 10), [rawId]) + + const { canEditEmailer } = useEmailerSettingsContext() + const { emailer } = useEmailerQuery({ + enabled: !isNaN(id), + id, + suspense: true, + }) + const { emailers } = useEmailersQuery({ suspense: true }) + const { updateAsync: updateEmailer } = useUpdateEmailer({ + id, + }) + + useEffect(() => { + if (isNaN(id) || !emailer) { + navigate(paths.notFound()) + } else if (!canEditEmailer) { + navigate('..', { replace: true }) + } + }, [id, emailer, navigate, canEditEmailer]) + + const onSubmit = async ({ name, is_primary, ...config }: FormValues) => { + try { + await updateEmailer({ + // @ts-expect-error: fixme + config: { + ...config, + host: config.smtp_host, + port: config.smtp_port, + }, + is_primary, + name, + }) + navigate(paths.settings('server/email')) + } catch (error) { + console.error(error) + // TODO:toast + } + } + + if (!emailer || !canEditEmailer) { + return null + } + + return ( + + + e.name) || []} + onSubmit={onSubmit} + /> + + + ) +} diff --git a/packages/browser/src/scenes/settings/server/email/EmailSettingsRouter.tsx b/packages/browser/src/scenes/settings/server/email/EmailSettingsRouter.tsx new file mode 100644 index 000000000..9578265f3 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/EmailSettingsRouter.tsx @@ -0,0 +1,42 @@ +import React, { lazy, Suspense, useEffect } from 'react' +import { Route, Routes, useNavigate } from 'react-router' + +import { useAppContext } from '@/context' + +import { EmailerSettingsContext } from './context.ts' + +const EmailSettingsScene = lazy(() => import('./EmailSettingsScene.tsx')) +const CreateEmailerScene = lazy(() => import('./CreateEmailerScene.tsx')) +const EditEmailerScene = lazy(() => import('./EditEmailerScene.tsx')) + +export default function EmailSettingsRouter() { + const navigate = useNavigate() + + const { checkPermission } = useAppContext() + + const canEdit = checkPermission('emailer:manage') + const canCreate = checkPermission('emailer:create') + const canView = checkPermission('emailer:read') + + useEffect(() => { + if (!canView) { + navigate('..', { replace: true }) + } + }, [canView, navigate]) + + if (!canView) return null + + return ( + + + + } /> + {canCreate && } />} + {canEdit && } />} + + + + ) +} diff --git a/packages/browser/src/scenes/settings/server/email/EmailSettingsScene.tsx b/packages/browser/src/scenes/settings/server/email/EmailSettingsScene.tsx new file mode 100644 index 000000000..67ce7ae25 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/EmailSettingsScene.tsx @@ -0,0 +1,27 @@ +import { useLocaleContext } from '@stump/i18n' +import React from 'react' +import { Helmet } from 'react-helmet' + +import { ContentContainer, SceneContainer } from '@/components/container' + +import { DevicesSection } from './devices' +import { EmailersSection } from './emailers' + +export default function EmailSettingsScene() { + const { t } = useLocaleContext() + + return ( + + + Stump | {t('settingsScene.server/email.helmet')} + + + +
+ + +
+
+
+ ) +} diff --git a/packages/browser/src/scenes/settings/server/email/context.ts b/packages/browser/src/scenes/settings/server/email/context.ts new file mode 100644 index 000000000..916248ba8 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/context.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react' + +export type IEmailerSettingsContext = { + canCreateEmailer: boolean + canEditEmailer: boolean +} + +export const EmailerSettingsContext = createContext({ + canCreateEmailer: false, + canEditEmailer: false, +}) + +export const useEmailerSettingsContext = () => useContext(EmailerSettingsContext) diff --git a/packages/browser/src/scenes/settings/server/email/devices/CreateOrUpdateDeviceModal.tsx b/packages/browser/src/scenes/settings/server/email/devices/CreateOrUpdateDeviceModal.tsx new file mode 100644 index 000000000..06df18b07 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/devices/CreateOrUpdateDeviceModal.tsx @@ -0,0 +1,115 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { emailerQueryKeys } from '@stump/api' +import { invalidateQueries, useCreateEmailDevice, useUpdateEmailDevice } from '@stump/client' +import { Button, CheckBox, Dialog, Form, Input } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { RegisteredEmailDevice } from '@stump/types' +import React, { useEffect, useMemo } from 'react' +import { useForm } from 'react-hook-form' +import toast from 'react-hot-toast' +import { z } from 'zod' + +type Props = { + isOpen: boolean + updatingDevice: RegisteredEmailDevice | null + onClose: () => void +} + +// TODO: unique constraint on name... +export default function CreateOrUpdateDeviceModal({ isOpen, updatingDevice, onClose }: Props) { + const { t } = useLocaleContext() + + const { createAsync } = useCreateEmailDevice() + const { updateAsync } = useUpdateEmailDevice({ + id: updatingDevice?.id || -1, + }) + + const defaultValues = useMemo( + () => ({ + email: updatingDevice?.email || '', + forbidden: updatingDevice?.forbidden || false, + name: updatingDevice?.name || '', + }), + [updatingDevice], + ) + + const form = useForm({ + defaultValues, + resolver: zodResolver(schema), + }) + const { reset } = form + + const isForbidden = form.watch('forbidden') + + useEffect(() => { + reset(defaultValues) + }, [defaultValues, reset, updatingDevice]) + + const handleSubmit = async (values: z.infer) => { + const handler = updatingDevice ? updateAsync : createAsync + try { + await handler(values) + await invalidateQueries({ keys: [emailerQueryKeys.getEmailDevices] }) + onClose() + } catch (error) { + console.error(error) + toast.error('Failed to create/update device') + } + } + + const onOpenChange = (nowOpen: boolean) => (nowOpen ? onClose() : null) + + return ( + + + + + {t(updatingDevice ? getKey('title.update') : getKey('title.create'))} + + + +
+
+ + + form.setValue('forbidden', !isForbidden)} + /> + +
+ + + + + +
+
+ ) +} + +const schema = z.object({ + email: z.string().email(), + forbidden: z.boolean().default(false), + name: z.string(), +}) + +const LOCALE_BASE = 'settingsScene.server/email.sections.devices.addOrUpdateDevice' +const getKey = (key: string) => `${LOCALE_BASE}.${key}` diff --git a/packages/browser/src/scenes/settings/server/email/devices/DeleteDeviceConfirmation.tsx b/packages/browser/src/scenes/settings/server/email/devices/DeleteDeviceConfirmation.tsx new file mode 100644 index 000000000..4c3453508 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/devices/DeleteDeviceConfirmation.tsx @@ -0,0 +1,46 @@ +import { emailerQueryKeys, isAxiosError } from '@stump/api' +import { invalidateQueries, useDeleteEmailDevice } from '@stump/client' +import { ConfirmationModal } from '@stump/components' +import { RegisteredEmailDevice } from '@stump/types' +import React, { useCallback } from 'react' +import toast from 'react-hot-toast' + +type Props = { + device: RegisteredEmailDevice | null + onClose: () => void +} +export default function DeleteDeviceConfirmation({ device, onClose }: Props) { + const { removeAsync, isDeleting } = useDeleteEmailDevice() + + const handleConfirm = useCallback(async () => { + if (!device) return + + try { + await removeAsync(device.id) + await invalidateQueries({ keys: [emailerQueryKeys.getEmailDevices] }) + onClose() + } catch (err) { + console.error(err) + + if (isAxiosError(err)) { + toast.error(err.message || 'An error occurred while deleting the list') + } else { + toast.error('An error occurred while deleting the list') + } + } + }, [onClose, device, removeAsync]) + + return ( + + ) +} diff --git a/packages/browser/src/scenes/settings/server/email/devices/DeviceActionMenu.tsx b/packages/browser/src/scenes/settings/server/email/devices/DeviceActionMenu.tsx new file mode 100644 index 000000000..51f8a8059 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/devices/DeviceActionMenu.tsx @@ -0,0 +1,39 @@ +import { DropdownMenu, IconButton } from '@stump/components' +import { Edit, MoreVertical, Trash2 } from 'lucide-react' +import React from 'react' + +type Props = { + onEdit: () => void + onDelete: () => void +} +export default function DeviceActionMenu({ onEdit, onDelete }: Props) { + return ( + , + onClick: onEdit, + }, + { + label: 'Delete', + leftIcon: , + onClick: onDelete, + }, + ], + }, + ]} + trigger={ + + + + } + align="end" + contentWrapperClassName="w-28 min-w-[unset]" + /> + ) +} + +const iconStyle = 'mr-2 h-4 w-4' diff --git a/packages/browser/src/scenes/settings/server/email/devices/DevicesSection.tsx b/packages/browser/src/scenes/settings/server/email/devices/DevicesSection.tsx new file mode 100644 index 000000000..192d981f0 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/devices/DevicesSection.tsx @@ -0,0 +1,57 @@ +import { Button, Heading, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { RegisteredEmailDevice } from '@stump/types' +import React, { Suspense, useState } from 'react' + +import { useEmailerSettingsContext } from '../context' +import CreateOrUpdateDeviceModal from './CreateOrUpdateDeviceModal' +import DevicesTable from './DevicesTable' + +export default function DevicesSection() { + const { t } = useLocaleContext() + const { canEditEmailer, canCreateEmailer } = useEmailerSettingsContext() + + const [isCreatingDevice, setIsCreatingDevice] = useState(false) + const [updatingDevice, setUpdatingDevice] = useState(null) + + const canCreateOrUpdate = canCreateEmailer || canEditEmailer + + return ( +
+
+
+ {t('settingsScene.server/email.sections.devices.title')} + + {t('settingsScene.server/email.sections.devices.description')} + +
+ + {canCreateEmailer && ( + + )} +
+ + + + + {canCreateOrUpdate && ( + { + setIsCreatingDevice(false) + setUpdatingDevice(null) + }} + /> + )} + +
+ ) +} diff --git a/packages/browser/src/scenes/settings/server/email/devices/DevicesTable.tsx b/packages/browser/src/scenes/settings/server/email/devices/DevicesTable.tsx new file mode 100644 index 000000000..8ab261fab --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/devices/DevicesTable.tsx @@ -0,0 +1,119 @@ +import { useEmailDevicesQuery } from '@stump/client' +import { Badge, Card, Heading, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { RegisteredEmailDevice } from '@stump/types' +import { createColumnHelper } from '@tanstack/react-table' +import { CircleSlash2 } from 'lucide-react' +import React, { useMemo, useState } from 'react' + +import { Table } from '@/components/table' + +import { useEmailerSettingsContext } from '../context' +import DeleteDeviceConfirmation from './DeleteDeviceConfirmation' +import DeviceActionMenu from './DeviceActionMenu' + +const columnHelper = createColumnHelper() +const baseColumns = [ + columnHelper.accessor('name', { + cell: ({ getValue }) => {getValue()}, + header: () => ( + + Name + + ), + }), + columnHelper.accessor('email', { + cell: ({ getValue }) => {getValue()}, + header: () => ( + + Email + + ), + }), + columnHelper.display({ + cell: ({ + row: { + original: { forbidden }, + }, + }) => ( + + {forbidden ? 'Forbidden' : 'Allowed'} + + ), + header: () => ( + + Status + + ), + id: 'status', + }), +] + +type Props = { + onSelectForUpdate: (device: RegisteredEmailDevice | null) => void +} + +export default function DevicesTable({ onSelectForUpdate }: Props) { + const { t } = useLocaleContext() + const { canEditEmailer } = useEmailerSettingsContext() + const { devices } = useEmailDevicesQuery() + + const [deletingDevice, setDeletingDevice] = useState(null) + + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) + + const columns = useMemo( + () => [ + ...baseColumns, + columnHelper.display({ + cell: ({ row: { original: device } }) => + canEditEmailer ? ( + onSelectForUpdate(device)} + onDelete={() => setDeletingDevice(device)} + /> + ) : null, + id: 'actions', + size: 0, + }), + ], + [onSelectForUpdate, canEditEmailer], + ) + + return ( + <> + {canEditEmailer && ( + setDeletingDevice(null)} /> + )} + + + ( +
+ +
+ {t(`${LOCALE_BASE}.emptyHeading`)} + + {t(`${LOCALE_BASE}.emptySubtitle`)} + +
+
+ )} + isZeroBasedPagination + /> + + + ) +} + +const LOCALE_BASE = 'settingsScene.server/email.sections.devices.table' diff --git a/packages/browser/src/scenes/settings/server/email/devices/index.ts b/packages/browser/src/scenes/settings/server/email/devices/index.ts new file mode 100644 index 000000000..9033c0aa0 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/devices/index.ts @@ -0,0 +1 @@ +export { default as DevicesSection } from './DevicesSection' diff --git a/packages/browser/src/scenes/settings/server/email/emailers/CreateOrUpdateEmailerForm.tsx b/packages/browser/src/scenes/settings/server/email/emailers/CreateOrUpdateEmailerForm.tsx new file mode 100644 index 000000000..2959865cd --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/CreateOrUpdateEmailerForm.tsx @@ -0,0 +1,259 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { + Button, + CheckBox, + Form, + Heading, + Input, + Label, + NativeSelect, + PasswordInput, + Text, +} from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { SMTPEmailer } from '@stump/types' +import React, { useMemo } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import { commonHosts, getCommonHost } from './utils' + +type Props = { + emailer?: SMTPEmailer + existingNames: string[] + onSubmit: (values: FormValues) => void +} + +// TODO: Some of the descriptions are LONG. Use tooltips where necessary, instead of inline descriptions. +export default function CreateOrUpdateEmailerForm({ emailer, existingNames, onSubmit }: Props) { + const { t } = useLocaleContext() + + const schema = useMemo( + () => + createSchema( + existingNames.filter((n) => n !== emailer?.name), + t, + !!emailer, + ), + [t, emailer, existingNames], + ) + const form = useForm({ + defaultValues: emailer + ? { + is_primary: emailer.is_primary, + max_attachment_size_bytes: emailer.config.max_attachment_size_bytes ?? undefined, + name: emailer.name, + sender_display_name: emailer.config.sender_display_name, + sender_email: emailer.config.sender_email, + smtp_host: emailer.config.smtp_host, + smtp_port: emailer.config.smtp_port, + tls_enabled: emailer.config.tls_enabled, + username: emailer.config.username, + } + : undefined, + resolver: zodResolver(schema), + }) + + const errors = useMemo(() => form.formState.errors, [form.formState.errors]) + + const [currentHost, tlsEnabled] = form.watch(['smtp_host', 'tls_enabled']) + const presetValue = useMemo(() => getCommonHost(currentHost)?.name.toLowerCase(), [currentHost]) + + const numericChangeHandler = + (key: keyof FormValues) => (e: React.ChangeEvent) => { + const { value } = e.target + + if (value === '' || value == undefined) { + form.setValue(key, undefined) + } else { + const parsed = parseInt(value) + if (!isNaN(parsed)) { + form.setValue(key, parsed) + } + } + } + const numericRegister = (key: keyof FormValues) => { + return { + ...form.register(key), + onChange: numericChangeHandler(key), + } + } + + return ( +
+ + +
+
+ + {t(`${LOCALE_BASE}.smtpSettings.heading`)} + + + + {t(`${LOCALE_BASE}.smtpSettings.description`)} + +
+ + {/* FIXME: A little buggy */} +
+ + { + const value = e.target.value + if (value && value in commonHosts) { + const preset = commonHosts[value] + if (preset) { + form.setValue('smtp_host', preset.smtp_host) + form.setValue('smtp_port', preset.smtp_port) + } + } + }} + /> + + {t(`${LOCALE_BASE}.smtpProvider.description`)} + +
+ +
+ + + +
+ +
+ + + +
+ + form.setValue('tls_enabled', !tlsEnabled)} + /> +
+ +
+
+ + {t(`${LOCALE_BASE}.senderSettings.heading`)} + + + + {t(`${LOCALE_BASE}.senderSettings.description`)} + +
+ + + +
+ +
+
+ + {t(`${LOCALE_BASE}.additionalSettings.heading`)} + + + + {t(`${LOCALE_BASE}.additionalSettings.description`)} + +
+ + +
+ +
+ +
+ + ) +} + +const LOCALE_BASE = 'settingsScene.server/email.createOrUpdateForm' +const FORBIDDEN_NAMES = ['new'] + +const createSchema = (existingNames: string[], _t: (key: string) => string, isCreating: boolean) => + z.object({ + is_primary: z.boolean().default(existingNames.length === 0), + max_attachment_size_bytes: z.number().optional(), + name: z.string().refine( + (name) => { + if (existingNames.includes(name)) { + return _t(`${LOCALE_BASE}.nameAlreadyExists`) + } else if (FORBIDDEN_NAMES.includes(name)) { + return _t(`${LOCALE_BASE}.nameIsForbidden`) + } + return true + }, + { message: _t(`${LOCALE_BASE}.validation.nameAlreadyExists`) }, + ), + password: isCreating ? z.string() : z.string().optional(), + sender_display_name: z.string(), + sender_email: z.string().email(), + smtp_host: z.string(), + smtp_port: z.number(), + tls_enabled: z.boolean().default(false), + username: z.string(), + }) +export type FormValues = z.infer> diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailerActionMenu.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailerActionMenu.tsx new file mode 100644 index 000000000..5a05d7d21 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailerActionMenu.tsx @@ -0,0 +1,39 @@ +import { DropdownMenu, IconButton } from '@stump/components' +import { Edit, MoreVertical, Trash2 } from 'lucide-react' +import React from 'react' + +type Props = { + onEdit: () => void + onDelete: () => void +} +export default function EmailerActionMenu({ onEdit, onDelete }: Props) { + return ( + , + onClick: onEdit, + }, + { + label: 'Delete', + leftIcon: , + onClick: onDelete, + }, + ], + }, + ]} + trigger={ + + + + } + align="end" + contentWrapperClassName="w-28 min-w-[unset]" + /> + ) +} + +const iconStyle = 'mr-2 h-4 w-4' diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailerListItem.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailerListItem.tsx new file mode 100644 index 000000000..ed63d4b76 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailerListItem.tsx @@ -0,0 +1,87 @@ +import { prefetchEmailerSendHistory } from '@stump/client' +import { Badge, Card, Text, ToolTip } from '@stump/components' +import { SMTPEmailer } from '@stump/types' +import dayjs from 'dayjs' +import { Sparkles } from 'lucide-react' +import React, { Suspense, useMemo } from 'react' +import { useNavigate } from 'react-router' + +import paths from '@/paths' + +import { useEmailerSettingsContext } from '../context' +import EmailerActionMenu from './EmailerActionMenu' +import EmailerSendHistory from './EmailerSendHistory' +import { getCommonHost } from './utils' + +type Props = { + emailer: SMTPEmailer +} +export default function EmailerListItem({ emailer }: Props) { + const navigate = useNavigate() + const { canEditEmailer } = useEmailerSettingsContext() + const { + name, + is_primary, + config: { smtp_host, smtp_port }, + last_used_at, + } = emailer + + const displayedHost = useMemo( + () => getCommonHost(smtp_host) ?? { name: smtp_host, smtp_host: smtp_host }, + [smtp_host], + ) + + const renderUsage = () => { + if (!last_used_at) { + return ( + + Not used yet + + ) + } else { + return + } + } + + return ( + prefetchEmailerSendHistory(emailer.id, { include_sent_by: true })} + > +
+ + {name} + +
+ {is_primary && ( + + + + )} + {canEditEmailer && ( + navigate(paths.editEmailer(emailer.id))} + // TODO: implement delete + onDelete={() => {}} + /> + )} +
+
+ +
+ + + {displayedHost.name} + + +
+ +
+ + {/* TODO: separate permission for viewing usage history? */} +
+ {renderUsage()} +
+ + ) +} diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistory.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistory.tsx new file mode 100644 index 000000000..0167bc8c4 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistory.tsx @@ -0,0 +1,78 @@ +import { useEmailerSendHistoryQuery } from '@stump/client' +import { Drawer, Text, ToolTip } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import dayjs from 'dayjs' +import localizedFormat from 'dayjs/plugin/localizedFormat' +import relativeTime from 'dayjs/plugin/relativeTime' +import React, { useState } from 'react' + +import GenericEmptyState from '@/components/GenericEmptyState' + +import EmailerSendHistoryTable from './EmailerSendHistoryTable' + +dayjs.extend(localizedFormat) +dayjs.extend(relativeTime) + +type Props = { + emailerId: number + lastUsedAt: dayjs.Dayjs +} + +export default function EmailerSendHistory({ emailerId, lastUsedAt }: Props) { + const { t } = useLocaleContext() + const { sendHistory } = useEmailerSendHistoryQuery({ + emailerId, + params: { include_sent_by: true }, + suspense: true, + }) + + const [drawerOpen, setDrawerOpen] = useState(false) + + const renderHistory = () => { + if (!sendHistory.length) { + return ( + + ) + } else { + return + } + } + + return ( + <> +
+ + setDrawerOpen(!drawerOpen)} + > + {lastUsedAt.fromNow()} + + +
+ + {/* TODO: clear the history option */} + setDrawerOpen(false)} onOpenChange={setDrawerOpen}> + +
+ + {t(getLocaleKey('heading'))} + +
+ +
{renderHistory()}
+
+
+ + ) +} + +const LOCALE_BASE = 'settingsScene.server/email.sections.emailers.list.sendHistory' +const getLocaleKey = (key: string) => `${LOCALE_BASE}.${key}` diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistoryTable.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistoryTable.tsx new file mode 100644 index 000000000..7a579c5a0 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistoryTable.tsx @@ -0,0 +1,173 @@ +import { cn, IconButton, Text, ToolTip } from '@stump/components' +import { EmailerSendRecord } from '@stump/types' +import { + createColumnHelper, + ExpandedState, + flexRender, + SortDirection, + useReactTable, +} from '@tanstack/react-table' +import dayjs from 'dayjs' +import { ChevronDown, Copy } from 'lucide-react' +import React, { useState } from 'react' + +import { getTableModels, SortIcon } from '@/components/table' + +import EmailerSendRecordAttachmentTable from './EmailerSendRecordAttachmentTable' + +type Props = { + records: EmailerSendRecord[] +} +export default function EmailerSendHistoryTable({ records }: Props) { + const [expanded, setExpanded] = useState({}) + + const table = useReactTable({ + columns, + data: records, + onExpandedChange: setExpanded, + state: { + expanded, + }, + ...getTableModels({ expanded: true, sorted: true }), + }) + + const { rows } = table.getRowModel() + + return ( +
+
+ + + {table.getFlatHeaders().map((header) => ( + + ))} + + + + + {rows.map((row) => ( + + + {row.getVisibleCells().map((cell) => ( + + ))} + + {row.getIsExpanded() && ( + + + + )} + + ))} + +
+
+ {flexRender(header.column.columnDef.header, header.getContext())} + +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ +
+
+ ) +} + +const columnHelper = createColumnHelper() +const columns = [ + columnHelper.accessor('sent_at', { + cell: ({ getValue }) => {dayjs(getValue()).format('LLL')}, + header: () => ( + + Sent at + + ), + id: 'sent_at', + }), + columnHelper.accessor('recipient_email', { + cell: ({ getValue }) => {getValue()}, + header: () => ( + + Recipient + + ), + id: 'recipient_email', + }), + columnHelper.display({ + cell: ({ + row: { + original: { sent_by, sent_by_user_id }, + }, + }) => { + if (sent_by) { + return {sent_by.username} + } else if (sent_by_user_id) { + return ( +
+ + + {sent_by_user_id.slice(0, 5)}..{sent_by_user_id.slice(-5)} + + + + {/* TODO: implement copy to clipboard */} + + + +
+ ) + } else { + return Unknown + } + }, + header: () => ( + + Sender + + ), + id: 'sender', + }), + // FIXME: multiple attachments in a single email + columnHelper.display({ + cell: ({ row }) => { + const { + original: { attachment_meta }, + } = row + + if (!attachment_meta) { + return None + } + + const isAlreadyExpanded = row.getIsExpanded() + return ( +
+ {isAlreadyExpanded ? 'Hide' : 'Show'} + + + +
+ ) + }, + header: () => ( + + Attachments + + ), + id: 'attachments-sub-table', + }), +] diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendRecordAttachmentTable.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendRecordAttachmentTable.tsx new file mode 100644 index 000000000..c68130f7a --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendRecordAttachmentTable.tsx @@ -0,0 +1,126 @@ +import { cn, Text } from '@stump/components' +import { AttachmentMeta } from '@stump/types' +import { + createColumnHelper, + flexRender, + SortDirection, + SortingState, + useReactTable, +} from '@tanstack/react-table' +import React, { useState } from 'react' +import AutoSizer from 'react-virtualized-auto-sizer' + +import { getTableModels, SortIcon } from '@/components/table' +import { usePreferences } from '@/hooks' +import { formatBytes } from '@/utils/format' + +type Props = { + attachments: AttachmentMeta[] +} +export default function EmailerSendRecordAttachmentTable({ attachments }: Props) { + const { + preferences: { enable_hide_scrollbar }, + } = usePreferences() + + const [sorting, setSorting] = useState([]) + + const table = useReactTable({ + columns, + data: attachments, + onSortingChange: setSorting, + state: { + sorting, + }, + ...getTableModels({ sorted: true }), + }) + + const { rows } = table.getRowModel() + + return ( + + {({ width }) => ( +
+ + + + {table.getFlatHeaders().map((header) => { + const isSortable = header.column.getCanSort() + return ( + + ) + })} + + + + + {rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+
+ {flexRender(header.column.columnDef.header, header.getContext())} + {isSortable && ( + + )} +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ )} +
+ ) +} + +const columnHelper = createColumnHelper() +const columns = [ + columnHelper.accessor('filename', { + cell: ({ getValue }) => {getValue()}, + header: () => ( + + Filename + + ), + }), + columnHelper.accessor('size', { + cell: ({ getValue }) => {formatBytes(getValue())}, + header: () => ( + + Size + + ), + }), +] diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailersList.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailersList.tsx new file mode 100644 index 000000000..0969b7d11 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailersList.tsx @@ -0,0 +1,44 @@ +import { useEmailersQuery } from '@stump/client' +import { ButtonOrLink, Card, Heading } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import { CircleSlash2 } from 'lucide-react' +import React from 'react' + +import paths from '@/paths' + +import { useEmailerSettingsContext } from '../context' +import EmailerListItem from './EmailerListItem' + +export default function EmailersList() { + const { t } = useLocaleContext() + const { canCreateEmailer } = useEmailerSettingsContext() + const { emailers } = useEmailersQuery({ + suspense: true, + }) + + if (!emailers?.length) { + return ( + + +
+ {t(`${LOCALE_BASE}.emptyHeading`)} + {canCreateEmailer && ( + + Create an emailer + + )} +
+
+ ) + } + + return ( +
+ {emailers.map((emailer) => ( + + ))} +
+ ) +} + +const LOCALE_BASE = 'settingsScene.server/email.sections.emailers.list' diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailersSection.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailersSection.tsx new file mode 100644 index 000000000..fa691f963 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailersSection.tsx @@ -0,0 +1,32 @@ +import { Alert, Heading, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import React, { Suspense } from 'react' + +import EmailersList from './EmailersList' + +export default function EmailersSection() { + const { t } = useLocaleContext() + + return ( +
+
+ {t('settingsScene.server/email.sections.emailers.title')} + + {t('settingsScene.server/email.sections.emailers.description')} + +
+ + + + + {t('settingsScene.server/email.sections.emailers.singleInstanceDisclaimer')} + + + + + + + +
+ ) +} diff --git a/packages/browser/src/scenes/settings/server/email/emailers/index.ts b/packages/browser/src/scenes/settings/server/email/emailers/index.ts new file mode 100644 index 000000000..5695e620a --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/index.ts @@ -0,0 +1,2 @@ +export { default as CreateOrUpdateEmailerForm, type FormValues } from './CreateOrUpdateEmailerForm' +export { default as EmailersSection } from './EmailersSection' diff --git a/packages/browser/src/scenes/settings/server/email/emailers/utils.ts b/packages/browser/src/scenes/settings/server/email/emailers/utils.ts new file mode 100644 index 000000000..b2f4bed6a --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/emailers/utils.ts @@ -0,0 +1,15 @@ +export const commonHosts = { + google: { + name: 'Google', + smtp_host: 'smtp.gmail.com', + smtp_port: 587, + }, + outlook: { + name: 'Outlook', + smtp_host: 'smtp.office365.com', + smtp_port: 587, + }, +} as Record + +export const getCommonHost = (host: string) => + Object.values(commonHosts).find(({ smtp_host }) => smtp_host === host) diff --git a/packages/browser/src/scenes/settings/server/email/index.ts b/packages/browser/src/scenes/settings/server/email/index.ts new file mode 100644 index 000000000..baf2c1350 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/email/index.ts @@ -0,0 +1 @@ +export { default as EmailSettingsRouter } from './EmailSettingsRouter' diff --git a/packages/browser/src/scenes/settings/server/general/GeneralServerSettingsScene.tsx b/packages/browser/src/scenes/settings/server/general/GeneralServerSettingsScene.tsx index 9ab33e1e1..73f8bb3b8 100644 --- a/packages/browser/src/scenes/settings/server/general/GeneralServerSettingsScene.tsx +++ b/packages/browser/src/scenes/settings/server/general/GeneralServerSettingsScene.tsx @@ -8,6 +8,7 @@ import { ContentContainer } from '@/components/container' import { SceneContainer } from '@/components/container' import ServerInfoSection from './ServerInfoSection' +import ServerPublicURL from './ServerPublicURL' export default function GeneralServerSettingsScene() { const { t } = useLocaleContext() @@ -31,6 +32,7 @@ export default function GeneralServerSettingsScene() { )} +
diff --git a/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx b/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx new file mode 100644 index 000000000..757685270 --- /dev/null +++ b/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx @@ -0,0 +1,23 @@ +import { Input } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' +import React from 'react' + +export default function ServerPublicURL() { + const { t } = useLocaleContext() + + // TODO: query for public URL + // TODO: debounced update of public URL + + return ( +
+ +
+ ) +} + +const LOCALE_BASE = 'settingsScene.server/general.sections.serverPublicUrl' +const getKey = (key: string) => `${LOCALE_BASE}.${key}` diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 3d2ab6ee1..3f77edfc7 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -28,6 +28,7 @@ export const queryClient = new QueryClient({ queries: { refetchOnWindowFocus: false, retry: false, + // TODO: change this and start using suspense, big big refactor... suspense: false, }, }, diff --git a/packages/client/src/queries/emailers.ts b/packages/client/src/queries/emailers.ts new file mode 100644 index 000000000..b1d1cf535 --- /dev/null +++ b/packages/client/src/queries/emailers.ts @@ -0,0 +1,202 @@ +import { emailerApi, emailerQueryKeys } from '@stump/api' +import { + CreateOrUpdateEmailDevice, + CreateOrUpdateEmailer, + EmailerIncludeParams, + EmailerSendRecord, + EmailerSendRecordIncludeParams, + RegisteredEmailDevice, + SendAttachmentEmailsPayload, + SMTPEmailer, +} from '@stump/types' +import { AxiosError } from 'axios' + +import { MutationOptions, queryClient, QueryOptions, useMutation, useQuery } from '../client' + +type UseEmailersQueryOptions = { + params?: EmailerIncludeParams +} & QueryOptions +export function useEmailersQuery({ params, ...options }: UseEmailersQueryOptions = {}) { + const { data: emailers, ...restReturn } = useQuery( + [emailerQueryKeys.getEmailers, params], + async () => { + const { data } = await emailerApi.getEmailers(params) + return data + }, + options, + ) + + return { + emailers, + ...restReturn, + } +} + +type UseEmailerQueryOptions = { id: number } & QueryOptions +export function useEmailerQuery({ id, ...options }: UseEmailerQueryOptions) { + const { data: emailer, ...restReturn } = useQuery( + [emailerQueryKeys.getEmailerById, id], + async () => { + const { data } = await emailerApi.getEmailerById(id) + return data + }, + options, + ) + + return { + emailer, + ...restReturn, + } +} + +type UseCreateEmailerOptions = { id: number } & MutationOptions< + SMTPEmailer, + AxiosError, + CreateOrUpdateEmailer +> +export function useUpdateEmailer({ id, ...options }: UseCreateEmailerOptions) { + const { + mutate: update, + mutateAsync: updateAsync, + ...restReturn + } = useMutation( + [emailerQueryKeys.updateEmailer], + (params) => emailerApi.updateEmailer(id, params).then((res) => res.data), + options, + ) + + return { + update, + updateAsync, + ...restReturn, + } +} + +type UseEmailerSendHistoryQueryOptions = { + emailerId: number + params?: EmailerSendRecordIncludeParams +} & QueryOptions +export function useEmailerSendHistoryQuery({ + emailerId, + params, + ...options +}: UseEmailerSendHistoryQueryOptions) { + const { data: sendHistory, ...restReturn } = useQuery( + [emailerQueryKeys.getEmailerSendHistory, emailerId, params], + async () => { + const { data } = await emailerApi.getEmailerSendHistory(emailerId, params) + return data + }, + options, + ) + + return { + sendHistory: sendHistory ?? [], + ...restReturn, + } +} +export const prefetchEmailerSendHistory = async ( + emailerId: number, + params?: EmailerSendRecordIncludeParams, +) => + queryClient.prefetchQuery( + [emailerQueryKeys.getEmailerSendHistory, emailerId, params], + async () => { + const { data } = await emailerApi.getEmailerSendHistory(emailerId, params) + return data + }, + ) + +type UseEmailDevicesQueryOptions = QueryOptions +export function useEmailDevicesQuery(options: UseEmailDevicesQueryOptions = {}) { + const { data, ...restReturn } = useQuery( + [emailerQueryKeys.getEmailDevices], + async () => { + const { data } = await emailerApi.getEmailDevices() + return data + }, + { + suspense: true, + ...options, + }, + ) + const devices = data || [] + + return { + devices, + ...restReturn, + } +} + +export function useSendAttachmentEmail() { + const { + mutate: send, + mutateAsync: sendAsync, + isLoading: isSending, + ...restReturn + } = useMutation([emailerQueryKeys.sendAttachmentEmail], (payload: SendAttachmentEmailsPayload) => + emailerApi.sendAttachmentEmail(payload).then((res) => res.data), + ) + + return { + isSending, + send, + sendAsync, + ...restReturn, + } +} + +export function useCreateEmailDevice() { + const { + mutate: create, + mutateAsync: createAsync, + ...restReturn + } = useMutation([emailerQueryKeys.createEmailDevice], emailerApi.createEmailDevice) + + return { + create, + createAsync, + ...restReturn, + } +} + +type UseUpdateEmailDeviceOptions = { id: number } & MutationOptions< + RegisteredEmailDevice, + AxiosError, + CreateOrUpdateEmailDevice +> +export function useUpdateEmailDevice({ id, ...options }: UseUpdateEmailDeviceOptions) { + const { + mutate: update, + mutateAsync: updateAsync, + ...restReturn + } = useMutation( + [emailerQueryKeys.updateEmailDevice], + (payload: CreateOrUpdateEmailDevice) => { + return emailerApi.updateEmailDevice(id, payload).then((res) => res.data) + }, + options, + ) + + return { + update, + updateAsync, + ...restReturn, + } +} + +export function useDeleteEmailDevice() { + const { + mutate: remove, + mutateAsync: removeAsync, + isLoading: isDeleting, + ...restReturn + } = useMutation([emailerQueryKeys.deleteEmailDevice], emailerApi.deleteEmailDevice) + + return { + isDeleting, + remove, + removeAsync, + ...restReturn, + } +} diff --git a/packages/client/src/queries/index.ts b/packages/client/src/queries/index.ts index 283c457ef..62c2ebe9a 100644 --- a/packages/client/src/queries/index.ts +++ b/packages/client/src/queries/index.ts @@ -8,6 +8,18 @@ export { useCreateBookClub, useUpdateBookClub, } from './bookClub' +export { + prefetchEmailerSendHistory, + useCreateEmailDevice, + useDeleteEmailDevice, + useEmailDevicesQuery, + useEmailerQuery, + useEmailerSendHistoryQuery, + useEmailersQuery, + useSendAttachmentEmail, + useUpdateEmailDevice, + useUpdateEmailer, +} from './emailers' export { type EpubActions, useEpub, useEpubLazy, type UseEpubReturn } from './epub' export { type DirectoryListingQueryParams, diff --git a/packages/components/src/dialog/ConfirmationModal.tsx b/packages/components/src/dialog/ConfirmationModal.tsx index b9378a521..d4a486219 100644 --- a/packages/components/src/dialog/ConfirmationModal.tsx +++ b/packages/components/src/dialog/ConfirmationModal.tsx @@ -1,3 +1,5 @@ +import { useCallback } from 'react' + import { Button } from '../button' import { PickSelect } from '../utils' import { Dialog } from './primitives' @@ -36,21 +38,26 @@ export function ConfirmationModal({ onConfirm, onClose, }: ConfirmationModalProps) { - const handleOpenChange = (nowOpen: boolean) => { - if (!nowOpen && !confirmIsLoading) { - onClose() - } - } + const handleOpenChange = useCallback( + (nowOpen: boolean) => { + if (!nowOpen && !confirmIsLoading) { + onClose() + } + }, + [confirmIsLoading, onClose], + ) return ( - - {typeof trigger === 'string' ? ( - - ) : ( - trigger - )} - + {trigger !== null && ( + + {typeof trigger === 'string' ? ( + + ) : ( + trigger + )} + + )} {title} diff --git a/packages/components/src/drawer/Drawer.tsx b/packages/components/src/drawer/Drawer.tsx index dd2b102c5..a97ecf9a9 100644 --- a/packages/components/src/drawer/Drawer.tsx +++ b/packages/components/src/drawer/Drawer.tsx @@ -43,13 +43,16 @@ const DrawerContent = React.forwardRef< {showTopIndicator && ( -
+ + } + /> + ) +}) +PasswordInput.displayName = 'PasswordInput' diff --git a/packages/components/src/input/index.ts b/packages/components/src/input/index.ts index 0a3d534f3..1349ce256 100644 --- a/packages/components/src/input/index.ts +++ b/packages/components/src/input/index.ts @@ -1,5 +1,6 @@ export { CheckBox, type CheckBoxProps } from './CheckBox' export { Input, type InputProps } from './Input' +export { PasswordInput } from './PasswordInput' export { RawCheckBox, type RawCheckBoxProps } from './raw/RawCheckBox' export { RawSwitch } from './raw/RawSwitch' export { RawTextArea, type RawTextAreaProps } from './raw/RawTextArea' diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 09fd6e3d5..de9783725 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -43,6 +43,22 @@ "fileChecksum": "Checksum" } }, + "emailBook": { + "heading": "Email book", + "description": "Send this book to device or email address", + "form": { + "email": { + "label": "Email", + "placeholder": "Email address", + "description": "An email address to send the book to. Click add to finalize each input" + }, + "devices": { + "label": "Devices", + "placeholder": "Select devices", + "noFilterMatch": "No devices match the filter" + } + } + }, "nextInSeries": "Next in Series" }, "createBookClubScene": { @@ -295,6 +311,7 @@ "users": "Users", "jobs": "Jobs", "access": "Access", + "email": "Email", "notifications": "Notifications", "label": "Server" } @@ -423,6 +440,10 @@ "date": "Build date" } } + }, + "serverPublicUrl": { + "label": "Public URL", + "description": "The URL that your server is accessible from outside your network, if applicable. This will enable invite links and other features" } } }, @@ -604,6 +625,135 @@ "createSubmitButton": "Create user", "updateSubmitButton": "Update user" } + }, + "server/email": { + "helmet": "Email settings", + "title": "Email", + "description": "Settings related to the configuring an SMTP emailer for your Stump server instance", + "sections": { + "emailers": { + "title": "SMTP emailers", + "description": "The clients you have configured for sending emails from your Stump server instance", + "singleInstanceDisclaimer": "While Stump supports multiple SMTP configurations in the backend, only one can be configured on the UI. This will be improved in the future", + "list": { + "emptyHeading": "No SMTP emailers configured", + "primaryEmailer": "Primary emailer", + "sendHistory": { + "heading": "Send history", + "description": "A record of emails sent from this emailer", + "table": { + "columns": { + "to": "To", + "subject": "Subject", + "status": "Status", + "createdAt": "Sent at" + } + }, + "emptyHeading": "No history", + "emptySubtitle": "The history of this emailer is empty. It has either not been used or the history has been cleared" + } + } + }, + "devices": { + "title": "Email devices", + "description": "The device aliases you have configured for recieving emails from your Stump server instance", + "addDevice": "Add device alias", + "addOrUpdateDevice": { + "title": { + "create": "Create device alias", + "update": "Update device alias" + }, + "name": { + "label": "Name", + "description": "A friendly name to uniquely identify this device alias" + }, + "email": { + "label": "Email", + "description": "The email address this alias will be associated with" + }, + "forbidden": { + "label": "Forbidden", + "description": "When enabled, no user may send emails to the address associated with this alias" + }, + "submit": { + "create": "Create device alias", + "update": "Update device alias" + } + }, + "table": { + "emptyHeading": "No devices registered", + "emptySubtitle": "Create a new device alias for future use" + } + } + }, + "createOrUpdateForm": { + "name": { + "label": "Name", + "description": "A friendly name to uniquely identify this emailer" + }, + "smtpSettings": { + "heading": "SMTP settings", + "description": "The SMTP-specific settings for this emailer" + }, + "smtpProvider": { + "label": "SMTP provider preset", + "description": "The provider of the SMTP server. This is used to prefill some common settings, but doesn't capture all providers" + }, + "smtpHost": { + "label": "SMTP host", + "description": "The hostname of the SMTP server" + }, + "smtpPort": { + "label": "SMTP port", + "description": "The port of the SMTP server" + }, + "tlsEnabled": { + "label": "TLS enabled", + "description": "If enabled, the connection to the SMTP server will be encrypted" + }, + "username": { + "label": "Username", + "description": "The username for authenticating with the SMTP server. This is typically the email address" + }, + "password": { + "label": "Password", + "description": "The password for authenticating with the SMTP server. If you're using Gmail, you may need to generate an app-specific password" + }, + "senderSettings": { + "heading": "Sender settings", + "description": "The settings for the sender of the email" + }, + "senderDisplayName": { + "label": "Sender display name", + "description": "The name that will be displayed to recipients" + }, + "senderEmail": { + "label": "Sender email address", + "description": "The email address that will be displayed to recipients" + }, + "additionalSettings": { + "heading": "Additional settings", + "description": "Additional settings for the emailer" + }, + "maxAttachmentSize": { + "label": "Max attachment size", + "description": "The maximum size, in bytes, that an attachment can be" + }, + "submit": { + "create": "Create emailer", + "update": "Update emailer" + } + }, + "createEmailer": { + "helmet": "Create emailer", + "title": "Create emailer", + "description": "Create a new SMTP emailer for your Stump server instance" + }, + "updateEmailer": { + "helmet": "Update emailer", + "title": "Update emailer", + "description": "Update the details of this SMTP emailer" + } } }, "jobOverlay": { @@ -682,7 +832,11 @@ }, "serverStatusOverlay": { "heading": "Server isn't connected", - "message": ["Please check your internet connection", "Click here", "to change your server URL"] + "message": [ + "Please check your internet connection", + "Click here", + "to change your server URL" + ] }, "slidingList": { "empty": "No items to display", @@ -708,13 +862,16 @@ } }, "common": { + "add": "Add", "cancel": "Cancel", "confirm": "Confirm", "save": "Save", + "send": "Send", "saveChanges": "Save changes", "create": "Create", "edit": "Edit", + "update": "Update", "unimplemented": "This functionality is not yet implemented! Check back later", "limitedFunctionality": "This is not yet fully implemented and is lacking some features. Check back later" } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/fr.json b/packages/i18n/src/locales/fr.json index c4de15eeb..bfdc582c4 100644 --- a/packages/i18n/src/locales/fr.json +++ b/packages/i18n/src/locales/fr.json @@ -47,63 +47,63 @@ }, "createBookClubScene": { "heading": "Créer un nouveau Club de Lecture", - "subtitle": "You can create a private book club a select few, or make it public for anyone on the server to join", + "subtitle": "Vous pouvez créer un club de lecture privé en sélectionnant des membres, ou le rendre public pour tous sur le serveur", "form": { "name": { - "label": "Name", - "placeholder": "My book club", - "description": "The name of your book club" + "label": "Nom", + "placeholder": "Mon club de lecture", + "description": "Le nom de votre club de lecture" }, "description": { "label": "Description", - "placeholder": "An 'Our Flag Means Death' fan club. We read pirate fiction to our hearts content", - "description": "An optional, short description of your book club" + "placeholder": "Un club de fans sur \"Notre Drapeau signifie la Mort\". Nous lisons des fictions sur la piraterie au gré de nos envies", + "description": "Une courte description facultative de votre club de lecture" }, "is_private": { - "label": "Private club", - "description": "If enabled, only users you invite will be able to join your book club" + "label": "Club privé", + "description": "Si activée, seuls les utilisateurs que vous invitez pourront rejoindre votre club de lecture" }, "member_role_spec": { - "heading": "Custom role mapping", + "heading": "Mappage des rôles personnalisé", "subtitle": [ "You can create custom names for the roles in your book club. For example, you could rename the 'Member' role to 'Crewmate', or 'Creator' to 'Captain'. If you don't want to use custom names, you can leave these fields blank and the default names will be used instead. For more information about roles, refer to the", "documentation" ], "member": { - "label": "Member", - "placeholder": "Member", - "description": "The name of the default role for your book club" + "label": "Membre", + "placeholder": "Membre", + "description": "Le nom du rôle par défaut pour votre club de lecture" }, "moderator": { - "label": "Moderator", - "placeholder": "Moderator", - "description": "The name of the moderator role for your book club" + "label": "Modérateur", + "placeholder": "Modérateur", + "description": "Le nom du rôle de modérateur pour votre club de lecture" }, "admin": { - "label": "Admin", - "placeholder": "Admin", - "description": "The name of the admin role for your book club" + "label": "Administrateur", + "placeholder": "Administrateur", + "description": "Le nom du rôle d'administrateur de votre club de lecture" }, "creator": { - "label": "Creator", - "placeholder": "Creator", - "description": "The name of the creator role for your book club. This is you!" + "label": "Créateur", + "placeholder": "Créateur", + "description": "Le nom du rôle de créateur de votre club de lecture. C'est vous !" } }, "creator_preferences": { "heading": "Your membership preferences", "subtitle": "Some preferences for your membership in the book club. These can be changed at any time from the book club settings page", "creator_display_name": { - "label": "Display Name", + "label": "Nom affiché", "placeholder": "oromei", - "description": "An optional display name for your membership in the book club. If set, this takes precedence over your username" + "description": "Un nom affiché facultatif pour votre adhésion au club de lecture. Si défini, celui-ci prend la priorité sur votre nom d'utilisateur" }, "creator_hide_progress": { - "label": "Hide Progress", - "description": "If enabled, your read progress will be hidden from other members of the book club" + "label": "Masquer la progression", + "description": "Si activée, votre progression de lecture sera cachée aux autres membres du club" } }, - "submit": "Create Book Club" + "submit": "Créer un Club de Lecture" } }, "createLibraryScene": { @@ -275,7 +275,7 @@ "settingsScene": { "navigation": { "general": "Général", - "logs": "Logs", + "logs": "Journaux", "server": "Serveur", "jobs": "Tâches et Configuration", "users": "Gestion des Utilisateurs", @@ -283,25 +283,26 @@ }, "sidebar": { "application": { - "account": "Account", + "account": "Compte", "appearance": "Apparence", + "reader": "Lecteur", "desktop": "Bureau", "label": "Application" }, "server": { - "general": "General", - "logs": "Logs", - "users": "Users", - "jobs": "Jobs", - "access": "Access", + "general": "Général", + "logs": "Journaux", + "users": "Utilisateurs", + "jobs": "Tâches", + "access": "Accéder", "notifications": "Notifications", - "label": "Server" + "label": "Serveur" } }, "app/account": { - "helmet": "Account settings", - "title": "Account settings", - "description": "Settings related to your account", + "helmet": "Paramètres du compte", + "title": "Paramètres du compte", + "description": "Paramètres liés à votre compte", "sections": { "account": { "validation": { @@ -370,21 +371,21 @@ } }, "app/reader": { - "helmet": "Reader settings", - "title": "Reader settings", - "description": "Default options for the Stump readers. These are bound to your current device only", + "helmet": "Paramètres du lecteur", + "title": "Paramètres du lecteur", + "description": "Options par défaut pour les lecteurs. Elles sont liées à votre appareil actuel uniquement", "sections": { "imageBasedBooks": { - "label": "Image-based books", - "description": "Comic books, manga, and other image-based books", + "label": "Livres illustrés", + "description": "Bandes dessinées, mangas, et autres livres illustrés", "sections": { "preloadAheadCount": { - "label": "Preload ahead count", - "description": "The number of pages to preload ahead of the current page" + "label": "Nombre de préchargements avant la page actuelle", + "description": "Le nombre de pages à précharger avant la page actuelle" }, "preloadBehindCount": { - "label": "Preload behind count", - "description": "The number of pages to preload behind the current page" + "label": "Nombre de préchargements après la page actuelle", + "description": "Le nombre de pages à précharger après la page actuelle" } } } @@ -411,41 +412,41 @@ "message": "Votre serveur n'est pas à jour. Veuillez le mettre à jour vers la dernière version !" }, "serverInfo": { - "title": "Server information", - "description": "Basic details about your Stump server instance", + "title": "Informations du serveur", + "description": "Détails de base sur l'instance de votre serveur Stump", "build": { - "label": "Build", - "description": "Details about the version and build", + "label": "Version", + "description": "Détails à propos de la version", "version": { "semver": "Version", - "commitHash": "Exact commit", - "date": "Build date" + "commitHash": "Commit exact", + "date": "Date de compilation" } } } } }, "server/logs": { - "helmet": "Logs", - "title": "Logs", - "description": "The logs generated by your Stump server instance", + "helmet": "Journaux", + "title": "Journaux", + "description": "Les journaux générés par votre instance de serveur Stump", "sections": { "persistedLogs": { - "title": "Persisted logs", - "description": "These logs have been manually persisted to the database, typically associated with a specific job or event", + "title": "Journaux persistants", + "description": "Ces journaux ont été enregistrés manuellement dans la base de données et sont généralement associés à un travail ou à un événement spécifique", "table": { "columns": { - "level": "Level", + "level": "Niveau", "message": "Message", - "timestamp": "Timestamp" + "timestamp": "Horodatage" }, - "emptyHeading": "No logs to display", - "emptySubtitle": "Your server is either very healthy or very unhealthy" + "emptyHeading": "Aucun journal à afficher", + "emptySubtitle": "Votre serveur est soit très sain, soit en très mauvais état" } }, "liveLogs": { - "title": "Live logs feed", - "description": "Streamed directly from your Stump server instance in real-time" + "title": "Flux des journaux en direct", + "description": "Diffusion directe depuis votre instance de serveur Stump en temps réel" } } }, @@ -536,8 +537,12 @@ "file": { "label": "Gestion des fichiers", "explorer": { - "label": "Explorateur de Fichiers", - "description": "Permet à l'utilisateur d'accéder à l'explorateur de fichiers des bibliothèques.\nLa restriction de contenu n'est pas prise en charge lorsque cette fonctionnalité est accordée" + "label": "File Explorer", + "description": "Allows the user to access the Library File Explorer.\nContent restriction is not supported when this feature is granted" + }, + "download": { + "label": "Télécharger les fichiers", + "description": "Permet à l'utilisateur de télécharger des fichiers à partir du serveur" }, "upload": { "label": "Envoyer des fichiers", @@ -576,16 +581,20 @@ }, "user": { "label": "Gestion des Utilisateurs", + "read": { + "label": "Lire des utilisateurs", + "description": "Permet à l'utilisateur de rechercher d'autres utilisateurs sur le serveur. Cette fonction est nécessaire pour certaines fonctionnalités, par exemple pour réduire l'accès à une bibliothèque pour des utilisateurs" + }, "manage": { "label": "Gérer les utilisateurs", - "description": "Permet à l'utilisateur de gérer d'autres utilisateurs sur le serveur.\nInclut les permissions pour créer et mettre à jour" + "description": "Allows the user to manage other users on the server.\nIncludes permissions to create and update" } }, "smartlist": { "label": "Listes intelligentes", "read": { "label": "Accéder à la fonctionnalité Liste Intelligente", - "description": "Permet à l'utilisateur d'accéder aux fonctionnalités des listes intelligentes" + "description": "Allows the user to access smart lists features" } } }, @@ -631,7 +640,7 @@ "home": "Accueil", "libraries": "Bibliothèques", "books": "Explorer", - "bookClubs": "Club de lecture", + "bookClubs": "Clubs de lecture", "createLibrary": "Créer une bibliothèque", "noLibraries": "Aucune bibliothèque", "createBookClub": "Créer un Club de Lecture", @@ -647,7 +656,7 @@ "libraryOptions": { "scanLibrary": "Scanner", "fileExplorer": "Explorateur de fichiers", - "manageLibrary": "Gérer", + "manageLibrary": "Manage", "deleteLibrary": "Supprimer" }, "versionInformation": { @@ -668,7 +677,7 @@ "web": { "message": "Une erreur de réseau s'est produite indiquant que votre serveur Stump est actuellement indisponible. Veuillez vous assurer qu'il est en cours d'exécution et accessible depuis cet appareil" }, - "reconnected": "Reconnecté au serveur ! Redirection...", + "reconnected": "Reconnecté au serveur! Redirection...", "reconnectionFailed": "Quelque chose s'est mal passé!" }, "serverStatusOverlay": { @@ -691,7 +700,7 @@ "placeholderNoTags": "Aucune étiquette disponible" }, "thumbnailDropdown": { - "label": "Éditer l'image de couverture", + "label": "Edit thumbnail", "options": { "selectFromBooks": "Sélectionner à partir des livres", "uploadImage": "Importer une image" @@ -708,6 +717,8 @@ "save": "Sauvegarder", "saveChanges": "Enregistrer les modifications", "create": "Créer", - "edit": "Éditer" + "edit": "Éditer", + "unimplemented": "Cette fonctionnalité n'est pas encore implémentée ! Revenez plus tard", + "limitedFunctionality": "Ceci n'est pas encore totalement implémenté et manque de certaines fonctionnalités. Revenez plus tard" } -} +} \ No newline at end of file diff --git a/packages/types/generated.ts b/packages/types/generated.ts index d2f997749..1144a1831 100644 --- a/packages/types/generated.ts +++ b/packages/types/generated.ts @@ -60,7 +60,7 @@ export type User = { id: string; username: string; is_server_owner: boolean; ava * Permissions that can be granted to a user. Some permissions are implied by others, * and will be automatically granted if the "parent" permission is granted. */ -export type UserPermission = "bookclub:read" | "bookclub:create" | "smartlist:read" | "file:explorer" | "file:upload" | "file:download" | "library:create" | "library:edit" | "library:scan" | "library:manage" | "library:delete" | "user:read" | "user:manage" | "notifier:read" | "notifier:create" | "notifier:manage" | "notifier:delete" | "server:manage" +export type UserPermission = "bookclub:read" | "bookclub:create" | "emailer:read" | "emailer:create" | "emailer:manage" | "email:send" | "email:arbitrary_send" | "smartlist:read" | "file:explorer" | "file:upload" | "file:download" | "library:create" | "library:edit" | "library:scan" | "library:manage" | "library:delete" | "user:read" | "user:manage" | "notifier:read" | "notifier:create" | "notifier:manage" | "notifier:delete" | "server:manage" export type AgeRestriction = { age: number; restrict_on_unset: boolean } @@ -68,6 +68,35 @@ export type UserPreferences = { id: string; locale: string; app_theme: string; s export type LoginActivity = { id: string; ip_address: string; user_agent: string; authentication_successful: boolean; timestamp: string; user?: User | null } +export type EmailerSendTo = { device_id: number } | { email: string } + +/** + * The config for an SMTP emailer + */ +export type EmailerConfig = { sender_email: string; sender_display_name: string; username: string; smtp_host: string; smtp_port: number; tls_enabled: boolean; max_attachment_size_bytes: number | null; max_num_attachments: number | null } + +export type EmailerClientConfig = { sender_email: string; sender_display_name: string; username: string; password: string; host: string; port: number; tls_enabled: boolean; max_attachment_size_bytes: number | null; max_num_attachments: number | null } + +/** + * An SMTP emailer entity, which stores SMTP configuration data to be used for sending emails. + * + * will be configurable. This will be expanded in the future. + */ +export type SMTPEmailer = { id: number; name: string; is_primary: boolean; config: EmailerConfig; last_used_at: string | null } + +export type RegisteredEmailDevice = { id: number; name: string; email: string; forbidden: boolean } + +/** + * A record of an email that was sent, used to keep track of emails that + * were sent by specific emailer(s) + */ +export type EmailerSendRecord = { id: number; emailer_id: number; recipient_email: string; attachment_meta: AttachmentMeta[] | null; sent_at: string; sent_by_user_id: string | null; sent_by?: User | null } + +/** + * The metadata of an attachment that was sent with an email + */ +export type AttachmentMeta = { filename: string; media_id: string | null; size: number } + export type FileStatus = "UNKNOWN" | "READY" | "UNSUPPORTED" | "ERROR" | "MISSING" export type Library = { id: string; name: string; description: string | null; emoji: string | null; path: string; status: string; updated_at: string; series: Series[] | null; tags: Tag[] | null; library_options: LibraryOptions } @@ -233,6 +262,8 @@ export type Pagination = null | PageQuery | CursorQuery // SERVER TYPE GENERATION +export type ClaimResponse = { is_claimed: boolean } + export type StumpVersion = { semver: string; rev: string; compile_time: string } export type UpdateCheck = { current_semver: string; latest_semver: string; has_update_available: boolean } @@ -247,7 +278,28 @@ export type UpdateUserPreferences = { id: string; locale: string; preferred_layo export type DeleteUser = { hard_delete: boolean | null } -export type ClaimResponse = { is_claimed: boolean } +export type EmailerIncludeParams = { include_send_history?: boolean } + +export type EmailerSendRecordIncludeParams = { include_sent_by?: boolean } + +export type SendAttachmentEmailsPayload = { media_ids: string[]; send_to: EmailerSendTo[] } + +export type SendAttachmentEmailResponse = { sent_emails_count: number; errors: string[] } + +/** + * Input object for creating or updating an emailer + */ +export type CreateOrUpdateEmailer = { name: string; is_primary: boolean; config: EmailerClientConfig } + +/** + * Input object for creating or updating an email device + */ +export type CreateOrUpdateEmailDevice = { name: string; email: string; forbidden: boolean } + +/** + * Patch an existing email device by its ID + */ +export type PatchEmailDevice = { name: string | null; email: string | null; forbidden: boolean | null } export type CreateLibrary = { name: string; path: string; description: string | null; tags: Tag[] | null; scan_mode: LibraryScanMode | null; library_options: LibraryOptions | null }