From d956ff393cbb8eab8f594d46863468c757f255df Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Sun, 16 Feb 2025 13:06:38 -0800 Subject: [PATCH] set up channels and email sending --- Cargo.lock | 399 ++++++++++++- Cargo.toml | 4 +- .../2025-02-04-070208_init_channels/down.sql | 4 + .../2025-02-04-070208_init_channels/up.sql | 29 + src/app_error.rs | 32 +- src/app_state.rs | 13 +- src/channel_selections.rs | 36 ++ src/channels.rs | 100 ++++ src/main.rs | 21 +- src/projects.rs | 26 +- src/router.rs | 552 +++++++++++++++++- src/schema.rs | 44 ++ src/settings.rs | 27 +- src/v0_router.rs | 62 +- templates/channel-email.html | 125 ++++ templates/channels.html | 63 ++ templates/project.html | 62 ++ templates/teams.html | 6 +- 18 files changed, 1561 insertions(+), 44 deletions(-) create mode 100644 migrations/2025-02-04-070208_init_channels/down.sql create mode 100644 migrations/2025-02-04-070208_init_channels/up.sql create mode 100644 src/channel_selections.rs create mode 100644 src/channels.rs create mode 100644 templates/channel-email.html create mode 100644 templates/channels.html create mode 100644 templates/project.html diff --git a/Cargo.lock b/Cargo.lock index e6def95..bb2ebf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,6 +305,7 @@ dependencies = [ "axum-core 0.5.0", "bytes", "cookie", + "form_urlencoded", "futures-util", "headers", "http 1.1.0", @@ -313,6 +314,8 @@ dependencies = [ "mime", "pin-project-lite", "serde", + "serde_html_form", + "serde_path_to_error", "tower", "tower-layer", "tower-service", @@ -503,6 +506,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "config" version = "0.14.1" @@ -789,6 +802,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -824,6 +848,22 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "email-encoding" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3d894bbbab314476b265f9b2d46bf24b123a36dd0e96b06a1b49545b9d9dcc" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.34" @@ -1128,6 +1168,17 @@ dependencies = [ "digest 0.9.0", ] +[[package]] +name = "hostname" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "windows", +] + [[package]] name = "http" version = "0.2.12" @@ -1345,6 +1396,124 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1361,6 +1530,27 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.6.0" @@ -1409,6 +1599,36 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lettre" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e882e1489810a45919477602194312b1a7df0e5acc30a6188be7b520268f63f8" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna 1.0.3", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "serde", + "socket2", + "tokio", + "tokio-native-tls", + "tracing", + "url", +] + [[package]] name = "libc" version = "0.2.161" @@ -1427,6 +1647,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + [[package]] name = "log" version = "0.4.22" @@ -1768,6 +1994,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "quote" version = "1.0.37" @@ -1777,6 +2012,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "rand" version = "0.8.5" @@ -2135,6 +2376,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_html_form" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_json" version = "1.0.132" @@ -2246,8 +2500,10 @@ dependencies = [ "diesel", "dotenvy", "futures", + "lettre", "oauth2", "rand", + "regex", "reqwest 0.12.8", "serde", "serde_json", @@ -2291,6 +2547,25 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stacker" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d08feb8f695b465baed819b03c128dc23f57a694510ab1f06c77f763975685e" +dependencies = [ + "cc", + "cfg-if 1.0.0", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2329,6 +2604,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -2454,6 +2740,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -2757,11 +3053,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.11.0" @@ -2911,6 +3219,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -3117,6 +3435,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + [[package]] name = "yaml-rust2" version = "0.8.1" @@ -3128,6 +3458,30 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -3149,8 +3503,51 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 486b16f..3247283 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,8 +25,10 @@ tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzi tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread", "tracing"] } deadpool-diesel = { version = "0.6.1", features = ["postgres", "serde"] } axum = { version = "0.8.1", features = ["macros"] } -axum-extra = { version = "0.10.0", features = ["cookie", "typed-header"] } +axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] } chrono = { version = "0.4.39", features = ["serde"] } base64 = "0.22.1" diesel = { version = "2.2.6", features = ["postgres", "chrono", "uuid"] } tower = "0.5.2" +regex = "1.11.1" +lettre = { version = "0.11.12", features = ["tokio1", "serde", "tracing", "tokio1-native-tls"] } diff --git a/migrations/2025-02-04-070208_init_channels/down.sql b/migrations/2025-02-04-070208_init_channels/down.sql new file mode 100644 index 0000000..58f5574 --- /dev/null +++ b/migrations/2025-02-04-070208_init_channels/down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS channel_selections; +DROP TABLE IF EXISTS slack_channels; +DROP TABLE IF EXISTS email_channels; +DROP TABLE IF EXISTS channels; diff --git a/migrations/2025-02-04-070208_init_channels/up.sql b/migrations/2025-02-04-070208_init_channels/up.sql new file mode 100644 index 0000000..acf2a89 --- /dev/null +++ b/migrations/2025-02-04-070208_init_channels/up.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS channels ( + id UUID NOT NULL PRIMARY KEY, + team_id UUID NOT NULL REFERENCES teams(id), + name TEXT NOT NULL, + enable_by_default BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS email_channels ( + id UUID NOT NULL PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, + recipient TEXT NOT NULL DEFAULT '', + verification_code TEXT NOT NULL DEFAULT '', + verification_code_guesses INT NOT NULL DEFAULT 0, + verified BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS slack_channels ( + id UUID NOT NULL PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, + oauth_state TEXT NOT NULL DEFAULT '', + access_token TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS channel_selections ( + project_id UUID NOT NULL REFERENCES projects(id), + channel_id UUID NOT NULL REFERENCES channels(id), + PRIMARY KEY (project_id, channel_id) +); +CREATE INDEX ON channel_selections (project_id); +CREATE INDEX ON channel_selections (channel_id); diff --git a/src/app_error.rs b/src/app_error.rs index c65a06a..ca02d73 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -1,4 +1,5 @@ -use anyhow::Error; +use std::fmt::{self, Display}; + use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; @@ -6,8 +7,10 @@ use axum::response::{IntoResponse, Response}; // For a simplified example of using anyhow in axum check /examples/anyhow-error-response #[derive(Debug)] pub enum AppError { - InternalServerError(Error), + InternalServerError(anyhow::Error), ForbiddenError(String), + NotFoundError(String), + BadRequestError(String), } // Tell axum how to convert `AppError` into a response. @@ -22,6 +25,14 @@ impl IntoResponse for AppError { tracing::info!("Forbidden: {}", client_message); (StatusCode::FORBIDDEN, client_message).into_response() } + Self::NotFoundError(client_message) => { + tracing::info!("Not found: {}", client_message); + (StatusCode::NOT_FOUND, client_message).into_response() + } + Self::BadRequestError(client_message) => { + tracing::info!("Bad user input: {}", client_message); + (StatusCode::BAD_REQUEST, client_message).into_response() + } } } } @@ -36,3 +47,20 @@ where Self::InternalServerError(Into::::into(err)) } } + +impl Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppError::InternalServerError(inner) => inner.fmt(f), + AppError::ForbiddenError(client_message) => { + write!(f, "ForbiddenError: {}", client_message) + } + AppError::NotFoundError(client_message) => { + write!(f, "NotFoundError: {}", client_message) + } + AppError::BadRequestError(client_message) => { + write!(f, "BadRequestError: {}", client_message) + } + } + } +} diff --git a/src/app_state.rs b/src/app_state.rs index 5dd1fdf..8558f98 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -3,18 +3,29 @@ use axum::{ http::request::Parts, }; use deadpool_diesel::postgres::{Connection, Pool}; +use lettre::SmtpTransport; use oauth2::basic::BasicClient; use crate::{app_error::AppError, sessions::PgStore, settings::Settings}; #[derive(Clone)] -pub(crate) struct AppState { +pub struct AppState { pub db_pool: Pool, + pub mailer: Mailer, pub oauth_client: BasicClient, pub session_store: PgStore, pub settings: Settings, } +#[derive(Clone)] +pub struct Mailer(pub SmtpTransport); + +impl FromRef for Mailer { + fn from_ref(state: &AppState) -> Self { + state.mailer.clone() + } +} + impl FromRef for PgStore { fn from_ref(state: &AppState) -> Self { state.session_store.clone() diff --git a/src/channel_selections.rs b/src/channel_selections.rs new file mode 100644 index 0000000..78f00ae --- /dev/null +++ b/src/channel_selections.rs @@ -0,0 +1,36 @@ +use diesel::{ + dsl::{auto_type, AsSelect}, + pg::Pg, + prelude::*, +}; +use uuid::Uuid; + +use crate::schema::channel_selections; + +#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)] +#[diesel(belongs_to(crate::channels::Channel))] +#[diesel(belongs_to(crate::projects::Project))] +#[diesel(primary_key(channel_id, project_id))] +#[diesel(check_for_backend(Pg))] +pub struct ChannelSelection { + pub project_id: Uuid, + pub channel_id: Uuid, +} + +impl ChannelSelection { + #[auto_type(no_type_alias)] + pub fn all() -> _ { + let select: AsSelect = Self::as_select(); + channel_selections::table.select(select) + } + + #[auto_type(no_type_alias)] + pub fn with_channel(channel_id: Uuid) -> _ { + channel_selections::channel_id.eq(channel_id) + } + + #[auto_type(no_type_alias)] + pub fn with_project(project_id: Uuid) -> _ { + channel_selections::project_id.eq(project_id) + } +} diff --git a/src/channels.rs b/src/channels.rs new file mode 100644 index 0000000..ea7a026 --- /dev/null +++ b/src/channels.rs @@ -0,0 +1,100 @@ +use anyhow::Context; +use deadpool_diesel::postgres::Connection; +use diesel::{ + dsl::{auto_type, insert_into, AsSelect}, + pg::Pg, + prelude::*, + Connection as _, +}; +use serde::Serialize; +use uuid::Uuid; + +use crate::{ + app_error::AppError, + schema::{channels, email_channels, slack_channels}, + teams::Team, +}; + +#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)] +#[diesel(belongs_to(Team))] +pub struct Channel { + pub id: Uuid, + pub team_id: Uuid, + pub name: String, + pub enable_by_default: bool, + + #[diesel(embed)] + pub email_data: Option, + #[diesel(embed)] + pub slack_data: Option, +} + +#[derive( + Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable, Serialize, +)] +#[diesel(belongs_to(Channel, foreign_key = id))] +pub struct EmailChannel { + pub id: Uuid, + pub recipient: String, + #[serde(skip_serializing)] + pub verification_code: String, + pub verification_code_guesses: i32, + pub verified: bool, +} + +#[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] +#[diesel(belongs_to(Channel, foreign_key = id))] +pub struct SlackChannel { + pub id: Uuid, + pub oauth_state: String, + pub access_token: String, + pub conversation_id: String, +} + +impl Channel { + #[auto_type(no_type_alias)] + pub fn all() -> _ { + let select: AsSelect = Channel::as_select(); + channels::table + .left_join(email_channels::table) + .left_join(slack_channels::table) + .select(select) + } + + #[auto_type(no_type_alias)] + pub fn with_id(channel_id: Uuid) -> _ { + channels::id.eq(channel_id) + } + + #[auto_type(no_type_alias)] + pub fn with_team(team_id: Uuid) -> _ { + channels::team_id.eq(team_id) + } + + pub async fn create_email_channel( + db_conn: &Connection, + team_id: Uuid, + ) -> Result { + let id = Uuid::now_v7(); + db_conn + .interact(move |conn| { + conn.transaction(move |conn| { + insert_into(channels::table) + .values(( + channels::id.eq(id.clone()), + channels::team_id.eq(team_id), + channels::name.eq("Untitled Email Channel"), + )) + .execute(conn)?; + insert_into(email_channels::table) + .values(email_channels::id.eq(id.clone())) + .execute(conn)?; + Self::all().filter(Self::with_id(id)).first(conn) + }) + }) + .await + .unwrap() + .context("Failed to insert new EmailChannel.") + .map_err(|err| err.into()) + } +} diff --git a/src/main.rs b/src/main.rs index 7815ad2..0721627 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,8 @@ mod api_keys; mod app_error; mod app_state; mod auth; +mod channel_selections; +mod channels; mod csrf; mod guards; mod messages; @@ -18,7 +20,12 @@ mod v0_router; use tracing_subscriber::EnvFilter; -use crate::{app_state::AppState, router::new_router, sessions::PgStore, settings::Settings}; +use crate::{ + app_state::{AppState, Mailer}, + router::new_router, + sessions::PgStore, + settings::Settings, +}; #[tokio::main] async fn main() { @@ -37,9 +44,21 @@ async fn main() { let session_store = PgStore::new(db_pool.clone()); + let mailer_creds = lettre::transport::smtp::authentication::Credentials::new( + settings.email.smtp_username.clone(), + settings.email.smtp_password.clone(), + ); + let mailer = Mailer( + lettre::SmtpTransport::starttls_relay(&settings.email.smtp_server) + .unwrap() + .credentials(mailer_creds) + .build(), + ); + let oauth_client = auth::new_oauth_client(&settings).unwrap(); let app_state = AppState { db_pool, + mailer, oauth_client, session_store, settings: settings.clone(), diff --git a/src/projects.rs b/src/projects.rs index 8dad9ca..c9b5249 100644 --- a/src/projects.rs +++ b/src/projects.rs @@ -1,11 +1,15 @@ use diesel::{ - dsl::{auto_type, AsSelect}, + dsl::{auto_type, AsSelect, Eq}, pg::Pg, prelude::*, }; use uuid::Uuid; -use crate::{schema::projects, teams::Team}; +use crate::{ + channels::Channel, + schema::{channel_selections, channels, email_channels, projects, slack_channels}, + teams::Team, +}; #[derive(Associations, Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] #[diesel(table_name = projects)] @@ -23,6 +27,11 @@ impl Project { projects::table.select(select) } + #[auto_type(no_type_alias)] + pub fn with_id(project_id: Uuid) -> _ { + projects::id.eq(project_id) + } + #[auto_type(no_type_alias)] pub fn with_team(team_id: Uuid) -> _ { projects::team_id.eq(team_id) @@ -32,4 +41,17 @@ impl Project { pub fn with_name(name: String) -> _ { projects::name.eq(name) } + + #[auto_type(no_type_alias)] + pub fn selected_channels(&self) -> _ { + let select: AsSelect = Channel::as_select(); + let project_filter: Eq = + channel_selections::project_id.eq(self.id); + channels::table + .left_join(email_channels::table) + .left_join(slack_channels::table) + .inner_join(channel_selections::table) + .filter(project_filter) + .select(select) + } } diff --git a/src/router.rs b/src/router.rs index aaeee0b..0460a10 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,12 +1,18 @@ -use anyhow::Context; +use std::collections::HashSet; + +use anyhow::{anyhow, Context}; use askama_axum::Template; use axum::{ extract::{Path, State}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, - Form, Router, + Router, }; -use diesel::{dsl::insert_into, prelude::*}; +use axum_extra::extract::Form; +use diesel::{delete, dsl::insert_into, prelude::*, update}; +use lettre::Transport as _; +use rand::{distributions::Uniform, Rng}; +use regex::Regex; use serde::Deserialize; use tower::ServiceBuilder; use tower_http::{ @@ -19,13 +25,15 @@ use uuid::Uuid; use crate::{ api_keys::ApiKey, app_error::AppError, - app_state::{AppState, DbConn}, + app_state::{AppState, DbConn, Mailer}, auth, + channel_selections::ChannelSelection, + channels::Channel, csrf::generate_csrf_token, guards, nav_state::{Breadcrumb, NavState}, projects::Project, - schema, + schema::{self, channel_selections, channels, email_channels}, settings::Settings, team_memberships::TeamMembership, teams::Team, @@ -36,14 +44,34 @@ use crate::{ pub fn new_router(state: AppState) -> Router<()> { let base_path = state.settings.base_path.clone(); Router::new().nest( - format!("{}", base_path).as_str(), + base_path.as_str(), Router::new() .route("/", get(landing_page)) .merge(v0_router::new_router(state.clone())) .route("/teams", get(teams_page)) .route("/teams/{team_id}", get(team_page)) .route("/teams/{team_id}/projects", get(projects_page)) + .route("/teams/{team_id}/projects/{project_id}", get(project_page)) + .route( + "/teams/{team_id}/projects/{project_id}/update-enabled-channels", + post(update_enabled_channels), + ) .route("/teams/{team_id}/new-api-key", post(post_new_api_key)) + .route("/teams/{team_id}/channels", get(channels_page)) + .route("/teams/{team_id}/channels/{channel_id}", get(channel_page)) + .route( + "/teams/{team_id}/channels/{channel_id}/update-channel", + post(update_channel), + ) + .route( + "/teams/{team_id}/channels/{channel_id}/update-email-recipient", + post(update_channel_email_recipient), + ) + .route( + "/teams/{team_id}/channels/{channel_id}/verify-email", + post(verify_email), + ) + .route("/teams/{team_id}/new-channel", post(post_new_channel)) .route("/new-team", get(new_team_page)) .route("/new-team", post(post_new_team)) .nest("/auth", auth::new_router()) @@ -250,3 +278,515 @@ async fn projects_page( ) .into_response()) } + +async fn channels_page( + State(Settings { base_path, .. }): State, + DbConn(db_conn): DbConn, + Path(team_id): Path, + CurrentUser(current_user): CurrentUser, +) -> Result { + let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; + + let team_filter = Channel::with_team(team_id); + let channels = db_conn + .interact(move |conn| Channel::all().filter(team_filter).load(conn)) + .await + .unwrap() + .context("Failed to load channels list.")?; + + let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id.clone())).await?; + let nav_state = NavState::new() + .set_base_path(&base_path) + .push_team(&team) + .push_slug(Breadcrumb { + href: "channels".to_string(), + label: "Channels".to_string(), + }) + .set_navbar_active_item("channels"); + #[derive(Template)] + #[template(path = "channels.html")] + struct ResponseTemplate { + base_path: String, + channels: Vec, + csrf_token: String, + nav_state: NavState, + } + Ok(Html( + ResponseTemplate { + base_path, + channels, + csrf_token, + nav_state, + } + .render()?, + ) + .into_response()) +} + +#[derive(Deserialize)] +struct NewChannelPostFormBody { + csrf_token: String, + channel_type: String, +} + +async fn post_new_channel( + State(Settings { base_path, .. }): State, + DbConn(db_conn): DbConn, + Path(team_id): Path, + CurrentUser(current_user): CurrentUser, + Form(form_body): Form, +) -> Result { + let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; + guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; + + let channel = match form_body.channel_type.as_str() { + "email" => Channel::create_email_channel(&db_conn, team.id.clone()).await?, + _ => { + return Err(AppError::BadRequestError( + "Channel type not recognized.".to_string(), + )); + } + }; + + Ok(Redirect::to(&format!( + "{}/teams/{}/channels/{}", + base_path, + team.id.simple(), + channel.id.simple() + ))) +} + +async fn channel_page( + State(Settings { base_path, .. }): State, + DbConn(db_conn): DbConn, + Path((team_id, channel_id)): Path<(Uuid, Uuid)>, + CurrentUser(current_user): CurrentUser, +) -> Result { + let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; + + let id_filter = Channel::with_id(channel_id); + let team_filter = Channel::with_team(team_id.clone()); + let channel = match db_conn + .interact(move |conn| { + Channel::all() + .filter(id_filter) + .filter(team_filter) + .first(conn) + .optional() + }) + .await + .unwrap()? + { + None => { + return Err(AppError::NotFoundError( + "Channel with that team and ID not found".to_string(), + )); + } + Some(channel) => channel, + }; + + let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id.clone())).await?; + let nav_state = NavState::new() + .set_base_path(&base_path) + .push_team(&team) + .push_slug(Breadcrumb { + href: "channels".to_string(), + label: "Channels".to_string(), + }) + .push_slug(Breadcrumb { + href: channel.id.simple().to_string(), + label: channel.name.clone(), + }) + .set_navbar_active_item("channels"); + + if channel.email_data.is_some() { + #[derive(Template)] + #[template(path = "channel-email.html")] + struct ResponseTemplate { + base_path: String, + channel: Channel, + csrf_token: String, + nav_state: NavState, + } + Ok(Html( + ResponseTemplate { + base_path, + channel, + csrf_token, + nav_state, + } + .render()?, + )) + } else if channel.slack_data.is_some() { + Err(anyhow::anyhow!("Slack channel config page is not yet implemented.").into()) + } else { + Err(anyhow::anyhow!( + "Channel doesn't have a recognized variant for which to render a config page." + ) + .into()) + } +} + +#[derive(Deserialize)] +struct UpdateChannelFormBody { + csrf_token: String, + name: String, + enable_by_default: Option, +} + +async fn update_channel( + State(Settings { base_path, .. }): State, + DbConn(db_conn): DbConn, + Path((team_id, channel_id)): Path<(Uuid, Uuid)>, + CurrentUser(current_user): CurrentUser, + Form(form_body): Form, +) -> Result { + guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; + guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; + + let id_filter = Channel::with_id(channel_id.clone()); + let team_filter = Channel::with_team(team_id.clone()); + let updated_rows = db_conn + .interact(move |conn| { + update(channels::table.filter(id_filter).filter(team_filter)) + .set(( + channels::name.eq(form_body.name), + channels::enable_by_default + .eq(form_body.enable_by_default.unwrap_or("false".to_string()) == "true"), + )) + .execute(conn) + }) + .await + .unwrap() + .context("Failed to load Channel while updating.")?; + if updated_rows != 1 { + return Err(AppError::NotFoundError( + "Channel with that team and ID not found".to_string(), + )); + } + Ok(Redirect::to(&format!( + "{}/teams/{}/channels/{}", + base_path, + team_id.simple(), + channel_id.simple() + )) + .into_response()) +} + +#[derive(Deserialize)] +struct UpdateChannelEmailRecipientFormBody { + // Yes it's a mouthful, but it's only used twice + csrf_token: String, + recipient: String, +} + +async fn update_channel_email_recipient( + State(Settings { + base_path, + email: email_settings, + .. + }): State, + DbConn(db_conn): DbConn, + State(Mailer(mailer)): State, + Path((team_id, channel_id)): Path<(Uuid, Uuid)>, + CurrentUser(current_user): CurrentUser, + Form(form_body): Form, +) -> Result { + guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; + guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; + + if !is_permissible_email(&form_body.recipient) { + return Err(AppError::BadRequestError( + "Unable to validate email address format.".to_string(), + )); + } + + let verification_code: String = rand::thread_rng() + .sample_iter(&Uniform::try_from(0..9).unwrap()) + .take(6) + .map(|n| n.to_string()) + .collect(); + + let verification_code_copy = verification_code.clone(); + let recipient_copy = form_body.recipient.clone(); + + let channel_id_filter = Channel::with_id(channel_id.clone()); + let email_channel_id_filter = email_channels::id.eq(channel_id.clone()); + let team_filter = Channel::with_team(team_id.clone()); + let updated_rows = db_conn + .interact(move |conn| { + if Channel::all() + .filter(channel_id_filter) + .filter(team_filter) + .filter(email_channel_id_filter.clone()) + .first(conn) + .optional() + .context("failed to check whether channel exists under team") + .map_err(Into::::into)? + .is_none() + { + return Err(AppError::NotFoundError( + "Channel with that team and ID not found.".to_string(), + )); + } + update(email_channels::table.filter(email_channel_id_filter)) + .set(( + email_channels::recipient.eq(recipient_copy), + email_channels::verification_code.eq(verification_code_copy), + email_channels::verification_code_guesses.eq(0), + )) + .execute(conn) + .map_err(Into::::into) + }) + .await + .unwrap()?; + if updated_rows != 1 { + return Err(anyhow!( + "Updating EmailChannel recipient, the channel was found but {} rows were updated.", + updated_rows + ) + .into()); + } + + tracing::debug!( + "Email verification code for {} is: {}", + form_body.recipient, + verification_code + ); + tracing::info!( + "Sending email verification code to: {}", + form_body.recipient + ); + let email = lettre::Message::builder() + .from(email_settings.verification_from.clone().into()) + .reply_to(email_settings.verification_from.clone().into()) + .to(form_body.recipient.parse()?) + .subject("Verify Your Email") + .header(lettre::message::header::ContentType::TEXT_PLAIN) + .body(format!( + "Your email verification code is: {}", + verification_code + ))?; + mailer.send(&email)?; + + Ok(Redirect::to(&format!( + "{}/teams/{}/channels/{}", + base_path, + team_id.simple(), + channel_id.simple() + ))) +} + +/** + * Returns true if the email address matches a format recognized as "valid". + * Not all "legal" email addresses will be accepted, but addresses that are + * "illegal" and/or could result in unexpected behavior should be rejected. + */ +fn is_permissible_email(address: &str) -> bool { + let re = Regex::new(r"^[a-zA-Z0-9._+-]+@([a-zA-Z0-9_-]+.)+[a-zA-Z]+$") + .expect("email validation regex should parse"); + re.is_match(address) +} + +#[derive(Deserialize)] +struct VerifyEmailFormBody { + csrf_token: String, + code: String, +} + +async fn verify_email( + State(Settings { base_path, .. }): State, + DbConn(db_conn): DbConn, + Path((team_id, channel_id)): Path<(Uuid, Uuid)>, + CurrentUser(current_user): CurrentUser, + Form(form_body): Form, +) -> Result { + guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; + guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; + + let channel_id_filter = Channel::with_id(channel_id.clone()); + let email_channel_id_filter = email_channels::id.eq(channel_id.clone()); + let team_filter = Channel::with_team(team_id.clone()); + let updated_rows = db_conn + .interact(move |conn| { + if Channel::all() + .filter(channel_id_filter) + .filter(team_filter) + .filter(email_channel_id_filter.clone()) + .first(conn) + .optional() + .context("failed to check whether channel exists under team") + .map_err(Into::::into)? + .is_none() + { + return Err(AppError::NotFoundError( + "Channel with that team and ID not found.".to_string(), + )); + } + update(email_channels::table.filter(email_channel_id_filter)) + .set( + email_channels::verification_code_guesses + .eq(email_channels::verification_code_guesses + 1), + ) + .execute(conn) + .map_err(Into::::into)?; + update( + email_channels::table + .filter(email_channel_id_filter) + .filter(email_channels::verification_code.eq(form_body.code)), + ) + .set(email_channels::verified.eq(true)) + .execute(conn) + .map_err(Into::::into) + }) + .await + .unwrap()?; + if updated_rows != 1 { + return Err(AppError::BadRequestError( + "Verification code not accepted.".to_string(), + )); + } + + Ok(Redirect::to(&format!( + "{}/teams/{}/channels/{}", + base_path, + team_id.simple(), + channel_id.simple() + ))) +} + +async fn project_page( + State(Settings { base_path, .. }): State, + DbConn(db_conn): DbConn, + Path((team_id, project_id)): Path<(Uuid, Uuid)>, + CurrentUser(current_user): CurrentUser, +) -> Result { + let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; + + let project_id_filter = Project::with_id(project_id.clone()); + let project_team_filter = Project::with_team(team_id.clone()); + let project = db_conn + .interact(move |conn| { + match Project::all() + .filter(project_id_filter) + .filter(project_team_filter) + .first(conn) + { + diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError( + "Project with that team and ID not found.".to_string(), + )), + other => other + .context("failed to load project") + .map_err(|err| err.into()), + } + }) + .await + .unwrap()?; + + let selected_channels_query = project.selected_channels(); + let enabled_channel_ids: HashSet = db_conn + .interact(move |conn| selected_channels_query.load(conn)) + .await + .unwrap() + .context("failed to load selected channels")? + .iter() + .map(|channel| channel.id) + .collect(); + + let team_filter = Channel::with_team(team.id.clone()); + let team_channels = db_conn + .interact(move |conn| Channel::all().filter(team_filter).load(conn)) + .await + .unwrap() + .context("failed to load team channels")?; + + let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; + let nav_state = NavState::new() + .set_base_path(&base_path) + .push_team(&team) + .push_project(&project)?; + + #[derive(Template)] + #[template(path = "project.html")] + struct ResponseTemplate { + base_path: String, + csrf_token: String, + enabled_channel_ids: HashSet, + nav_state: NavState, + project: Project, + team_channels: Vec, + } + Ok(Html( + ResponseTemplate { + base_path, + csrf_token, + enabled_channel_ids, + project, + nav_state, + team_channels, + } + .render()?, + )) +} + +#[derive(Deserialize)] +struct UpdateEnabledChannelsFormBody { + csrf_token: String, + #[serde(default)] + enabled_channels: Vec, +} + +async fn update_enabled_channels( + State(Settings { base_path, .. }): State, + DbConn(db_conn): DbConn, + Path((team_id, project_id)): Path<(Uuid, Uuid)>, + CurrentUser(current_user): CurrentUser, + Form(form_body): Form, +) -> Result { + guards::require_valid_csrf_token(&form_body.csrf_token, ¤t_user, &db_conn).await?; + guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; + + let id_filter = Project::with_id(project_id.clone()); + let team_filter = Project::with_team(team_id.clone()); + db_conn + .interact(move |conn| -> Result<(), AppError> { + let project = match Project::all() + .filter(id_filter) + .filter(team_filter) + .first(conn) + { + diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError( + "Project with that team and ID not found.".to_string(), + )), + other => other + .context("failed to load project") + .map_err(|err| err.into()), + }?; + delete( + channel_selections::table + .filter(ChannelSelection::with_project(project.id.clone())) + .filter(channel_selections::channel_id.ne_all(&form_body.enabled_channels)), + ) + .execute(conn) + .context("failed to remove unset channel selections")?; + for channel_id in form_body.enabled_channels { + insert_into(channel_selections::table) + .values(( + channel_selections::project_id.eq(&project.id), + channel_selections::channel_id.eq(channel_id), + )) + .on_conflict_do_nothing() + .execute(conn) + .context("failed to insert channel selections")?; + } + Ok(()) + }) + .await + .unwrap()?; + + Ok(Redirect::to(&format!( + "{}/teams/{}/projects/{}", + base_path, team_id, project_id + )) + .into_response()) +} diff --git a/src/schema.rs b/src/schema.rs index a2c001c..bf093ae 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -18,6 +18,22 @@ diesel::table! { } } +diesel::table! { + channel_selections (project_id, channel_id) { + project_id -> Uuid, + channel_id -> Uuid, + } +} + +diesel::table! { + channels (id) { + id -> Uuid, + team_id -> Uuid, + name -> Text, + enable_by_default -> Bool, + } +} + diesel::table! { csrf_tokens (id) { id -> Uuid, @@ -26,6 +42,16 @@ diesel::table! { } } +diesel::table! { + email_channels (id) { + id -> Uuid, + recipient -> Text, + verification_code -> Text, + verification_code_guesses -> Int4, + verified -> Bool, + } +} + diesel::table! { messages (id) { id -> Uuid, @@ -43,6 +69,15 @@ diesel::table! { } } +diesel::table! { + slack_channels (id) { + id -> Uuid, + oauth_state -> Text, + access_token -> Text, + conversation_id -> Text, + } +} + diesel::table! { team_memberships (team_id, user_id) { team_id -> Uuid, @@ -67,18 +102,27 @@ diesel::table! { } diesel::joinable!(api_keys -> teams (team_id)); +diesel::joinable!(channel_selections -> channels (channel_id)); +diesel::joinable!(channel_selections -> projects (project_id)); +diesel::joinable!(channels -> teams (team_id)); diesel::joinable!(csrf_tokens -> users (user_id)); +diesel::joinable!(email_channels -> channels (id)); diesel::joinable!(messages -> projects (project_id)); diesel::joinable!(projects -> teams (team_id)); +diesel::joinable!(slack_channels -> channels (id)); diesel::joinable!(team_memberships -> teams (team_id)); diesel::joinable!(team_memberships -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( api_keys, browser_sessions, + channel_selections, + channels, csrf_tokens, + email_channels, messages, projects, + slack_channels, team_memberships, teams, users, diff --git a/src/settings.rs b/src/settings.rs index b0a3b25..fa42e00 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -18,7 +18,9 @@ pub struct Settings { #[serde(default = "default_port")] pub port: u16, - pub auth: Auth, + pub auth: AuthSettings, + + pub email: EmailSettings, } fn default_port() -> u16 { @@ -30,7 +32,7 @@ fn default_host() -> String { } #[derive(Clone, Debug, Deserialize)] -pub struct Auth { +pub struct AuthSettings { pub client_id: String, pub client_secret: String, pub redirect_url: String, @@ -46,10 +48,27 @@ fn default_cookie_name() -> String { "SHOUT_DOT_DEV_SESSION".to_string() } +#[derive(Clone, Debug, Deserialize)] +pub struct EmailSettings { + pub verification_from: lettre::Address, + pub message_from: lettre::Address, + pub smtp_server: String, + pub smtp_username: String, + pub smtp_password: String, +} + +pub struct SlackSettings { + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, + pub auth_url: String, + pub token_url: String, +} + impl Settings { pub fn load() -> Result { - if let Err(_) = dotenv() { - println!("Couldn't load .env file."); + if let Err(err) = dotenv() { + println!("Couldn't load .env file: {:?}", err); } let s = Config::builder() .add_source(Environment::default()) diff --git a/src/v0_router.rs b/src/v0_router.rs index 10e8d0f..f49a2b9 100644 --- a/src/v0_router.rs +++ b/src/v0_router.rs @@ -1,21 +1,23 @@ use anyhow::Context; use axum::{ - extract::Query, + extract::{Query, State}, response::{IntoResponse, Json}, routing::get, Router, }; use diesel::{dsl::insert_into, prelude::*, update}; -use serde::{Deserialize, Serialize}; +use lettre::Transport as _; +use serde::Deserialize; +use serde_json::json; use uuid::Uuid; use crate::{ api_keys::ApiKey, app_error::AppError, - app_state::{AppState, DbConn}, - messages::Message, + app_state::{AppState, DbConn, Mailer}, projects::Project, - schema::{api_keys, messages, projects}, + schema::{api_keys, projects}, + settings::Settings, }; pub fn new_router(state: AppState) -> Router { @@ -30,6 +32,11 @@ struct SayQuery { } async fn say_get( + State(Settings { + email: email_settings, + .. + }): State, + State(Mailer(mailer)): State, DbConn(db_conn): DbConn, Query(query): Query, ) -> Result { @@ -50,7 +57,7 @@ async fn say_get( None => return Err(AppError::ForbiddenError("key not accepted".to_string())), }; let project_name = query.project.to_lowercase(); - let project = db_conn + let selected_channels = db_conn .interact(move |conn| { insert_into(projects::table) .values(( @@ -60,30 +67,43 @@ async fn say_get( )) .on_conflict((projects::team_id, projects::name)) .do_nothing() - .execute(conn)?; + .execute(conn) + .context("failed to insert project")?; // It would be nice to merge these two database operations into one, // but it's not trivial to do so without faking an update; refer to: // https://stackoverflow.com/a/42217872 - Project::all() + let project = Project::all() .filter(Project::with_team(api_key.team_id)) .filter(Project::with_name(project_name)) .first(conn) + .context("failed to load project")?; + project + .selected_channels() + .load(conn) + .context("failed to load selected channels") }) .await .unwrap() .context("unable to get project")?; - db_conn - .interact(move |conn| { - insert_into(messages::table) - .values(Message::values_now(project.id, query.message)) - .execute(conn) - }) - .await - .unwrap() - .context("unable to insert message")?; - #[derive(Serialize)] - struct ResponseBody { - ok: bool, + + for channel in selected_channels { + if let Some(email_data) = channel.email_data { + if email_data.verified { + let recipient: lettre::Address = email_data.recipient.parse()?; + let email = lettre::Message::builder() + .from(email_settings.message_from.clone().into()) + .reply_to(email_settings.message_from.clone().into()) + .to(recipient.into()) + .subject("Shout") + .header(lettre::message::header::ContentType::TEXT_PLAIN) + .body(query.message.clone())?; + tracing::info!("Sending email to recipient for channel {}", channel.id); + mailer.send(&email)?; + } else { + tracing::info!("Email recipient for channel {} is not verified", channel.id); + } + } } - Ok(Json(ResponseBody { ok: true })) + + Ok(Json(json!({ "ok": true }))) } diff --git a/templates/channel-email.html b/templates/channel-email.html new file mode 100644 index 0000000..c180c0c --- /dev/null +++ b/templates/channel-email.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} + +{% block title %}Shout.dev: Channels{% endblock %} + +{% block main %} +{% let email_data = channel.email_data.clone().unwrap() %} +{% include "breadcrumbs.html" %} +
+
+

Channel Configuration

+
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
+
+ + +
+ {% if email_data.verified %} + Updating this will require verification of the new recipient. + {% else %} + Recipient must be verified before they can receive messages. + {% endif %} +
+
+
+ + +
+
+
+ {% if email_data.recipient != "" && !email_data.verified %} +
+
+
+ + +
+ Enter the most recent Shout.dev verification code for this address. + +
+
+
+ + +
+
+
+ + +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/channels.html b/templates/channels.html new file mode 100644 index 0000000..fdbcd62 --- /dev/null +++ b/templates/channels.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block title %}Shout.dev: Channels{% endblock %} + +{% block main %} +{% include "breadcrumbs.html" %} +
+
+
+

Channels

+
+ +
+
+
+ +
+ + + {% for channel in channels %} + + + + {% endfor %} + +
+ + {{ channel.name }} + +
+
+
+{% endblock %} diff --git a/templates/project.html b/templates/project.html new file mode 100644 index 0000000..cc99c04 --- /dev/null +++ b/templates/project.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} + +{% block title %}Shout.dev: Projects: {{ project.name }}{% endblock %} + +{% block main %} +{% include "breadcrumbs.html" %} +
+
+

Project: {{ project.name }}

+
+
+

Enabled Channels

+
+
+ + + + + + + + + {% for channel in team_channels %} + + + + + {% endfor %} + +
Channel NameEnabled
+ + + +
+
+
+ + +
+
+
+
+{% endblock %} diff --git a/templates/teams.html b/templates/teams.html index d3f2c52..a607459 100644 --- a/templates/teams.html +++ b/templates/teams.html @@ -3,11 +3,7 @@ {% block title %}Shout.dev: Teams{% endblock %} {% block main %} - +{% include "breadcrumbs.html" %}