set up channels and email sending

This commit is contained in:
Brent Schroeter 2025-02-26 13:10:45 -08:00
parent 2acb922979
commit d051b97810
18 changed files with 1561 additions and 44 deletions

399
Cargo.lock generated
View file

@ -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",
]

View file

@ -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"] }

View file

@ -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;

View file

@ -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);

View file

@ -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::<anyhow::Error>::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)
}
}
}
}

View file

@ -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<AppState> for Mailer {
fn from_ref(state: &AppState) -> Self {
state.mailer.clone()
}
}
impl FromRef<AppState> for PgStore {
fn from_ref(state: &AppState) -> Self {
state.session_store.clone()

36
src/channel_selections.rs Normal file
View file

@ -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<ChannelSelection, Pg> = 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)
}
}

100
src/channels.rs Normal file
View file

@ -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<EmailChannel>,
#[diesel(embed)]
pub slack_data: Option<SlackChannel>,
}
#[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, Pg> = 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<Self, AppError> {
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())
}
}

View file

@ -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(),

View file

@ -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, Pg> = Channel::as_select();
let project_filter: Eq<channel_selections::project_id, Uuid> =
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)
}
}

View file

@ -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<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_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<Channel>,
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<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<NewChannelPostFormBody>,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
guards::require_valid_csrf_token(&form_body.csrf_token, &current_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<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_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<String>,
}
async fn update_channel(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelFormBody>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_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<Settings>,
DbConn(db_conn): DbConn,
State(Mailer(mailer)): State<Mailer>,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateChannelEmailRecipientFormBody>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_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::<AppError>::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::<AppError>::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<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, channel_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<VerifyEmailFormBody>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_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::<AppError>::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::<AppError>::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::<AppError>::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<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, project_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_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<Uuid> = 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<Uuid>,
nav_state: NavState,
project: Project,
team_channels: Vec<Channel>,
}
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<Uuid>,
}
async fn update_enabled_channels(
State(Settings { base_path, .. }): State<Settings>,
DbConn(db_conn): DbConn,
Path((team_id, project_id)): Path<(Uuid, Uuid)>,
CurrentUser(current_user): CurrentUser,
Form(form_body): Form<UpdateEnabledChannelsFormBody>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form_body.csrf_token, &current_user, &db_conn).await?;
guards::require_team_membership(&current_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())
}

View file

@ -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,

View file

@ -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<Self, ConfigError> {
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())

View file

@ -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<AppState> {
@ -30,6 +32,11 @@ struct SayQuery {
}
async fn say_get(
State(Settings {
email: email_settings,
..
}): State<Settings>,
State(Mailer(mailer)): State<Mailer>,
DbConn(db_conn): DbConn,
Query(query): Query<SayQuery>,
) -> Result<impl IntoResponse, AppError> {
@ -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 })))
}

View file

@ -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" %}
<main class="container mt-5">
<section class="mb-4">
<h1>Channel Configuration</h1>
</section>
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-channel"
>
<div class="mb-3">
<label for="channel-name-input" class="form-label">Channel Name</label>
<input
type="text"
class="form-control"
id="channel-name-input"
name="name"
value="{{ channel.name }}"
>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input
class="form-check-input"
{% if channel.enable_by_default %}
checked=""
{% endif %}
type="checkbox"
name="enable_by_default"
value="true"
role="switch"
id="channel-default-enabled-switch"
>
<label class="form-check-label" for="channel-default-enabled-switch">
Enable by default for new projects in this team
</label>
</div>
</div>
<div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</section>
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
>
<div class="mb-3">
<label for="channel-recipient-input" class="form-label">Recipient Email</label>
<input
type="text"
class="form-control"
id="channel-recipient-input"
name="recipient"
value="{{ email_data.recipient }}"
aria-describedby="channel-recipient-help"
>
<div id="channel-recipient-help" class="form-text">
{% if email_data.verified %}
Updating this will require verification of the new recipient.
{% else %}
Recipient must be verified before they can receive messages.
{% endif %}
</div>
</div>
<div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Send Verification</button>
</div>
</form>
</section>
{% if email_data.recipient != "" && !email_data.verified %}
<section class="mb-4">
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/verify-email"
>
<div class="mb-3">
<label for="channel-recipient-verification-code" class="form-label">
Verification Code
</label>
<input
type="text"
class="form-control"
name="code"
id="channel-recipient-verification-code"
aria-describedby="channel-recipient-verification-code-help"
>
<div id="channel-recipient-verification-code-help" class="form-text">
Enter the most recent Shout.dev verification code for this address.
<input
type="submit"
form="email-verification-form"
class="btn btn-link align-baseline p-0"
type="submit"
style="font-size: inherit;"
value="Re-send verification email"
>
</div>
</div>
<div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Verify</button>
</div>
</form>
<form
id="email-verification-form"
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}/update-email-recipient"
>
<input type="hidden" name="recipient" value="{{ email_data.recipient }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
</form>
</section>
{% endif %}
</main>
{% endblock %}

63
templates/channels.html Normal file
View file

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Shout.dev: Channels{% endblock %}
{% block main %}
{% include "breadcrumbs.html" %}
<main class="container mt-5">
<section class="mb-4">
<div class="d-flex justify-content-between align-items-center">
<h1>Channels</h1>
<div>
<div class="dropdown">
<!-- FIXME: a11y https://getbootstrap.com/docs/5.3/components/dropdowns/#accessibility -->
<button
class="btn btn-primary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
New Channel
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item disabled" href="#">Slack (coming soon)</a></li>
<li>
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/new-channel"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="channel_type" value="email">
<button class="dropdown-item" type="submit">Email</button>
</form>
</li>
<li><a class="dropdown-item disabled" href="#">SMS (coming soon)</a></li>
</ul>
</div>
</div>
</div>
</section>
<div class="alert alert-primary" role="alert">
Channels are places to send messages, alerts, and so on. Once created, they
can be connected to specific projects at the
<a
href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/projects"
>Projects page</a>.
</div>
<section class="mb-3">
<table class="table">
<tbody>
{% for channel in channels %}
<tr>
<td>
<a href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}">
{{ channel.name }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
{% endblock %}

62
templates/project.html Normal file
View file

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Shout.dev: Projects: {{ project.name }}{% endblock %}
{% block main %}
{% include "breadcrumbs.html" %}
<main class="container mt-5">
<section class="mb-4">
<h1>Project: <code>{{ project.name }}</code></h1>
</section>
<section class="mb-4">
<h2>Enabled Channels</h2>
<form
method="post"
action="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/projects/{{ project.id.simple() }}/update-enabled-channels"
>
<div class="mb-3">
<table class="table">
<thead>
<tr>
<th>Channel Name</th>
<th>Enabled</th>
</tr>
</thead>
<tbody>
{% for channel in team_channels %}
<tr>
<td>
<label for="enable-channel-switch-{{ channel.id.simple() }}">
<a
target="_blank"
href="{{ base_path }}/teams/{{ nav_state.team_id.unwrap().simple() }}/channels/{{ channel.id.simple() }}"
>
{{ channel.name }}
</a>
</label>
</td>
<td>
<input
class="form-check-input"
{% if enabled_channel_ids.contains(channel.id) %}
checked=""
{% endif %}
type="checkbox"
name="enabled_channels"
value="{{ channel.id.simple() }}"
id="enable-channel-switch-{{ channel.id.simple() }}"
>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</section>
</main>
{% endblock %}

View file

@ -3,11 +3,7 @@
{% block title %}Shout.dev: Teams{% endblock %}
{% block main %}
<nav class="container mt-4" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">Teams</li>
</ol>
</nav>
{% include "breadcrumbs.html" %}
<main class="container mt-5">
<section class="mb-4">
<div class="d-flex justify-content-between align-items-center">