diff --git a/Cargo.lock b/Cargo.lock
index 630858ee..68f3dcbd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4442,11 +4442,13 @@ version = "0.11.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cb54db6ff7a89efac87dba5baeac57bb9ccd726b49a9b6f21fb92b3966aaf56"
dependencies = [
+ "async-trait",
"base64 0.22.1",
"chumsky",
"email-encoding",
"email_address",
"fastrand 2.3.0",
+ "futures-io",
"futures-util",
"hostname",
"httpdate",
@@ -4459,6 +4461,7 @@ dependencies = [
"rustls-native-certs 0.8.1",
"socket2 0.6.0",
"tokio",
+ "tokio-rustls 0.26.2",
"url",
]
diff --git a/Cargo.toml b/Cargo.toml
index 07485bb3..5f0a461f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -87,7 +87,9 @@ lettre = { version = "0.11.18", default-features = false, features = [
"ring",
"rustls",
"rustls-native-certs",
+ "tokio1-rustls",
"smtp-transport",
+ "tokio1",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.29.1", default-features = false }
diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local
index fa70155e..0dc3fbc5 100644
--- a/apps/labrinth/.env.local
+++ b/apps/labrinth/.env.local
@@ -6,6 +6,7 @@ SITE_URL=http://localhost:3000
# This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH
CDN_URL=file:///tmp/modrinth
LABRINTH_ADMIN_KEY=feedbeef
+LABRINTH_EXTERNAL_NOTIFICATION_KEY=beeffeed
RATE_LIMIT_IGNORE_KEY=feedbeef
DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
diff --git a/apps/labrinth/.sqlx/query-006813fc9b61e5333484e7c6443f0325fd64f9ab965fed3f973adeced8719128.json b/apps/labrinth/.sqlx/query-006813fc9b61e5333484e7c6443f0325fd64f9ab965fed3f973adeced8719128.json
new file mode 100644
index 00000000..cb92e14c
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-006813fc9b61e5333484e7c6443f0325fd64f9ab965fed3f973adeced8719128.json
@@ -0,0 +1,46 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n COALESCE(unp.id, dnp.id) \"id!\",\n unp.user_id,\n dnp.channel \"channel!\",\n dnp.notification_type \"notification_type!\",\n COALESCE(unp.enabled, dnp.enabled, false) \"enabled!\"\n FROM users_notifications_preferences dnp\n LEFT JOIN users_notifications_preferences unp\n ON unp.channel = dnp.channel\n AND unp.notification_type = dnp.notification_type\n AND unp.user_id = ANY($1::bigint[])\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "channel!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "notification_type!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 4,
+ "name": "enabled!",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ null,
+ true,
+ false,
+ false,
+ null
+ ]
+ },
+ "hash": "006813fc9b61e5333484e7c6443f0325fd64f9ab965fed3f973adeced8719128"
+}
diff --git a/apps/labrinth/.sqlx/query-0339cb166cfc7e78fc1269d5d1547a772977b269d6d01a64a1f93acb86f9e411.json b/apps/labrinth/.sqlx/query-0339cb166cfc7e78fc1269d5d1547a772977b269d6d01a64a1f93acb86f9e411.json
new file mode 100644
index 00000000..9b4a45df
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-0339cb166cfc7e78fc1269d5d1547a772977b269d6d01a64a1f93acb86f9e411.json
@@ -0,0 +1,76 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT n.id, n.user_id, n.name, n.text, n.link, n.created, n.read, n.type notification_type, n.body,\n JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'name', na.name, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions\n FROM notifications n\n LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id\n INNER JOIN notifications_types nt on nt.name = n.body ->> 'type'\n WHERE n.user_id = $1\n AND nt.expose_in_site_notifications = TRUE\n GROUP BY n.id, n.user_id\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "name",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "text",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 4,
+ "name": "link",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 5,
+ "name": "created",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 6,
+ "name": "read",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 7,
+ "name": "notification_type",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 8,
+ "name": "body",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 9,
+ "name": "actions",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ true,
+ true,
+ false,
+ false,
+ true,
+ true,
+ null
+ ]
+ },
+ "hash": "0339cb166cfc7e78fc1269d5d1547a772977b269d6d01a64a1f93acb86f9e411"
+}
diff --git a/apps/labrinth/.sqlx/query-0c425b9e08bd7a8cefce82adf87cca44340bd51b012ca2fb19a095f1c6038437.json b/apps/labrinth/.sqlx/query-0c425b9e08bd7a8cefce82adf87cca44340bd51b012ca2fb19a095f1c6038437.json
new file mode 100644
index 00000000..0f258683
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-0c425b9e08bd7a8cefce82adf87cca44340bd51b012ca2fb19a095f1c6038437.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n DELETE FROM notifications_deliveries\n WHERE notification_id = ANY($1)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "0c425b9e08bd7a8cefce82adf87cca44340bd51b012ca2fb19a095f1c6038437"
+}
diff --git a/apps/labrinth/.sqlx/query-0e29dad2b228ca4922811bb45f05f39145489302a4e9bc25eeed49c97d3dc01e.json b/apps/labrinth/.sqlx/query-0e29dad2b228ca4922811bb45f05f39145489302a4e9bc25eeed49c97d3dc01e.json
new file mode 100644
index 00000000..662538c8
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-0e29dad2b228ca4922811bb45f05f39145489302a4e9bc25eeed49c97d3dc01e.json
@@ -0,0 +1,35 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n users.username \"user_name!\",\n users.email \"user_email\",\n project.name \"project_name!\"\n FROM users\n INNER JOIN mods project ON project.id = $1\n WHERE users.id = $2\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_name!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_email",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "project_name!",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false
+ ]
+ },
+ "hash": "0e29dad2b228ca4922811bb45f05f39145489302a4e9bc25eeed49c97d3dc01e"
+}
diff --git a/apps/labrinth/.sqlx/query-5f7ce5881b9051f2a2e88577f8851a8e367c8914fa40ff2224dcb907284339d8.json b/apps/labrinth/.sqlx/query-5f7ce5881b9051f2a2e88577f8851a8e367c8914fa40ff2224dcb907284339d8.json
new file mode 100644
index 00000000..e99410a8
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-5f7ce5881b9051f2a2e88577f8851a8e367c8914fa40ff2224dcb907284339d8.json
@@ -0,0 +1,66 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n id, notification_id, user_id, channel, delivery_priority, status, next_attempt, attempt_count\n FROM notifications_deliveries\n WHERE\n status = $3\n AND channel = $1\n AND next_attempt <= NOW()\n ORDER BY\n delivery_priority DESC,\n next_attempt ASC\n LIMIT $2\n FOR UPDATE\n SKIP LOCKED\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "notification_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "user_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 3,
+ "name": "channel",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 4,
+ "name": "delivery_priority",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 5,
+ "name": "status",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "next_attempt",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 7,
+ "name": "attempt_count",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Int8",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "5f7ce5881b9051f2a2e88577f8851a8e367c8914fa40ff2224dcb907284339d8"
+}
diff --git a/apps/labrinth/.sqlx/query-66f890fcf2761869e5580c82ea5054c8e5ce839fb4a6c2d94b9621b57cb0e02c.json b/apps/labrinth/.sqlx/query-66f890fcf2761869e5580c82ea5054c8e5ce839fb4a6c2d94b9621b57cb0e02c.json
new file mode 100644
index 00000000..244422f6
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-66f890fcf2761869e5580c82ea5054c8e5ce839fb4a6c2d94b9621b57cb0e02c.json
@@ -0,0 +1,25 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO users_notifications_preferences (\n user_id, channel, notification_type, enabled\n )\n VALUES ($1, $2, $3, $4)\n RETURNING id\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Varchar",
+ "Varchar",
+ "Bool"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "66f890fcf2761869e5580c82ea5054c8e5ce839fb4a6c2d94b9621b57cb0e02c"
+}
diff --git a/apps/labrinth/.sqlx/query-8399e818bbe8642304b2e30dcac511f8242cb66d6daedfdcd9627462dc08b2f1.json b/apps/labrinth/.sqlx/query-8399e818bbe8642304b2e30dcac511f8242cb66d6daedfdcd9627462dc08b2f1.json
new file mode 100644
index 00000000..126e94c0
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-8399e818bbe8642304b2e30dcac511f8242cb66d6daedfdcd9627462dc08b2f1.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n WITH\n channels AS (\n SELECT channel FROM UNNEST($1::varchar[]) AS t(channel)\n ),\n delivery_candidates AS (\n SELECT\n ids.notification_id,\n ids.user_id,\n channels.channel,\n nt.delivery_priority,\n uprefs.enabled user_enabled,\n dprefs.enabled default_enabled\n FROM\n UNNEST(\n $2::bigint[],\n $3::bigint[],\n $4::varchar[]\n ) AS ids(notification_id, user_id, notification_type)\n CROSS JOIN channels\n INNER JOIN\n notifications_types nt ON nt.name = ids.notification_type\n LEFT JOIN users_notifications_preferences uprefs\n ON uprefs.user_id = ids.user_id\n AND uprefs.channel = channels.channel\n AND uprefs.notification_type = ids.notification_type\n LEFT JOIN users_notifications_preferences dprefs\n ON dprefs.user_id IS NULL\n AND dprefs.channel = channels.channel\n AND dprefs.notification_type = ids.notification_type\n )\n INSERT INTO notifications_deliveries\n (notification_id, user_id, channel, delivery_priority, status, next_attempt, attempt_count)\n SELECT\n dc.notification_id,\n dc.user_id,\n dc.channel,\n dc.delivery_priority,\n CASE\n -- User explicitly enabled\n WHEN user_enabled = TRUE THEN $5\n\n -- Is enabled by default, no preference by user\n WHEN user_enabled IS NULL AND default_enabled = TRUE THEN $5\n\n -- User explicitly disabled (regardless of default)\n WHEN user_enabled = FALSE THEN $6\n\n -- User set no preference, default disabled\n WHEN user_enabled IS NULL AND default_enabled = FALSE THEN $7\n\n -- At this point, user set no preference and there is no\n -- default set, so treat as disabled-by-default.\n ELSE $7\n END status,\n NOW() next_attempt,\n 0 attempt_count\n FROM\n delivery_candidates dc\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "VarcharArray",
+ "Int8Array",
+ "Int8Array",
+ "VarcharArray",
+ "Text",
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "8399e818bbe8642304b2e30dcac511f8242cb66d6daedfdcd9627462dc08b2f1"
+}
diff --git a/apps/labrinth/.sqlx/query-91e4b5a08579246e2eca91c1c38f0e8ff3d11077e172f103b65044aab2f90a91.json b/apps/labrinth/.sqlx/query-91e4b5a08579246e2eca91c1c38f0e8ff3d11077e172f103b65044aab2f90a91.json
new file mode 100644
index 00000000..3d482060
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-91e4b5a08579246e2eca91c1c38f0e8ff3d11077e172f103b65044aab2f90a91.json
@@ -0,0 +1,42 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n users.username \"user_name!\",\n users.email \"user_email\",\n inviter.username \"inviter_name!\",\n project.name \"project_name!\"\n FROM users\n INNER JOIN users inviter ON inviter.id = $1\n INNER JOIN mods project ON project.id = $2\n WHERE users.id = $3\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_name!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_email",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "inviter_name!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "project_name!",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false,
+ false
+ ]
+ },
+ "hash": "91e4b5a08579246e2eca91c1c38f0e8ff3d11077e172f103b65044aab2f90a91"
+}
diff --git a/apps/labrinth/.sqlx/query-971bbd54f168da93b39b8550776157ff82a679798ea198e52091c75d31bc5e7c.json b/apps/labrinth/.sqlx/query-971bbd54f168da93b39b8550776157ff82a679798ea198e52091c75d31bc5e7c.json
new file mode 100644
index 00000000..58cb6757
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-971bbd54f168da93b39b8550776157ff82a679798ea198e52091c75d31bc5e7c.json
@@ -0,0 +1,42 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n users.username \"user_name!\",\n users.email \"user_email\",\n inviter.username \"inviter_name!\",\n organization.name \"organization_name!\"\n FROM users\n INNER JOIN users inviter ON inviter.id = $1\n INNER JOIN organizations organization ON organization.id = $2\n WHERE users.id = $3\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "user_name!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_email",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "inviter_name!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "organization_name!",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false,
+ false
+ ]
+ },
+ "hash": "971bbd54f168da93b39b8550776157ff82a679798ea198e52091c75d31bc5e7c"
+}
diff --git a/apps/labrinth/.sqlx/query-a04c04cfb025e36dddd78638fd042792dbf6a1d83a15d0d08b5ce589063eefd4.json b/apps/labrinth/.sqlx/query-a04c04cfb025e36dddd78638fd042792dbf6a1d83a15d0d08b5ce589063eefd4.json
new file mode 100644
index 00000000..cd2a80dd
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-a04c04cfb025e36dddd78638fd042792dbf6a1d83a15d0d08b5ce589063eefd4.json
@@ -0,0 +1,64 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n id, notification_id, user_id, channel, delivery_priority, status, next_attempt, attempt_count\n FROM notifications_deliveries\n WHERE user_id = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "notification_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "user_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 3,
+ "name": "channel",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 4,
+ "name": "delivery_priority",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 5,
+ "name": "status",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "next_attempt",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 7,
+ "name": "attempt_count",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "a04c04cfb025e36dddd78638fd042792dbf6a1d83a15d0d08b5ce589063eefd4"
+}
diff --git a/apps/labrinth/.sqlx/query-a92900cba0e27410d29910c991b9a161ef58e39455454e5b3a380ed62eb15eb2.json b/apps/labrinth/.sqlx/query-a92900cba0e27410d29910c991b9a161ef58e39455454e5b3a380ed62eb15eb2.json
new file mode 100644
index 00000000..6b10485c
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-a92900cba0e27410d29910c991b9a161ef58e39455454e5b3a380ed62eb15eb2.json
@@ -0,0 +1,18 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE notifications_deliveries\n SET\n delivery_priority = $2,\n status = $3,\n next_attempt = $4,\n attempt_count = $5\n WHERE id = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int4",
+ "Varchar",
+ "Timestamptz",
+ "Int4"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "a92900cba0e27410d29910c991b9a161ef58e39455454e5b3a380ed62eb15eb2"
+}
diff --git a/apps/labrinth/.sqlx/query-b3371c0ff555f8f90ced4c4b1f397863e65d9aafe06f77703db18b492e6a9c03.json b/apps/labrinth/.sqlx/query-b3371c0ff555f8f90ced4c4b1f397863e65d9aafe06f77703db18b492e6a9c03.json
new file mode 100644
index 00000000..3f589630
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-b3371c0ff555f8f90ced4c4b1f397863e65d9aafe06f77703db18b492e6a9c03.json
@@ -0,0 +1,52 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT * FROM notifications_templates WHERE channel = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "channel",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "notification_type",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "subject_line",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "body_fetch_url",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "plaintext_fallback",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "b3371c0ff555f8f90ced4c4b1f397863e65d9aafe06f77703db18b492e6a9c03"
+}
diff --git a/apps/labrinth/.sqlx/query-dc05295852b5a1d49be7906cd248566ffdfe790d7b61bd69969b00d558b41804.json b/apps/labrinth/.sqlx/query-bc21f3bef3585780f445725576ca6a1a9e89a896a8e8cfaae46137d22d40a837.json
similarity index 92%
rename from apps/labrinth/.sqlx/query-dc05295852b5a1d49be7906cd248566ffdfe790d7b61bd69969b00d558b41804.json
rename to apps/labrinth/.sqlx/query-bc21f3bef3585780f445725576ca6a1a9e89a896a8e8cfaae46137d22d40a837.json
index a6e27474..4850efeb 100644
--- a/apps/labrinth/.sqlx/query-dc05295852b5a1d49be7906cd248566ffdfe790d7b61bd69969b00d558b41804.json
+++ b/apps/labrinth/.sqlx/query-bc21f3bef3585780f445725576ca6a1a9e89a896a8e8cfaae46137d22d40a837.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT n.id, n.user_id, n.name, n.text, n.link, n.created, n.read, n.type notification_type, n.body,\n JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'name', na.name, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions\n FROM notifications n\n LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id\n WHERE n.user_id = $1\n GROUP BY n.id, n.user_id;\n ",
+ "query": "\n SELECT n.id, n.user_id, n.name, n.text, n.link, n.created, n.read, n.type notification_type, n.body,\n JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'name', na.name, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions\n FROM notifications n\n LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id\n WHERE n.user_id = $1\n GROUP BY n.id, n.user_id\n ",
"describe": {
"columns": [
{
@@ -72,5 +72,5 @@
null
]
},
- "hash": "dc05295852b5a1d49be7906cd248566ffdfe790d7b61bd69969b00d558b41804"
+ "hash": "bc21f3bef3585780f445725576ca6a1a9e89a896a8e8cfaae46137d22d40a837"
}
diff --git a/apps/labrinth/.sqlx/query-c8ae8b814a1877a5fd3919a87ad41ed4ac11e74f3640594939fd964ee7bf75c0.json b/apps/labrinth/.sqlx/query-c8ae8b814a1877a5fd3919a87ad41ed4ac11e74f3640594939fd964ee7bf75c0.json
new file mode 100644
index 00000000..a2b45531
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-c8ae8b814a1877a5fd3919a87ad41ed4ac11e74f3640594939fd964ee7bf75c0.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO notifications_deliveries (\n notification_id, user_id, channel, delivery_priority, status, next_attempt, attempt_count\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8",
+ "Varchar",
+ "Int4",
+ "Varchar",
+ "Timestamptz",
+ "Int4"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "c8ae8b814a1877a5fd3919a87ad41ed4ac11e74f3640594939fd964ee7bf75c0"
+}
diff --git a/apps/labrinth/.sqlx/query-f39c5338f0776255c35d13c98e4d4e10bb9a871d420a3315aa8617bb2aa0d679.json b/apps/labrinth/.sqlx/query-f39c5338f0776255c35d13c98e4d4e10bb9a871d420a3315aa8617bb2aa0d679.json
new file mode 100644
index 00000000..dce17e61
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-f39c5338f0776255c35d13c98e4d4e10bb9a871d420a3315aa8617bb2aa0d679.json
@@ -0,0 +1,38 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT * FROM notifications_types",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "name",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "delivery_priority",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 2,
+ "name": "expose_in_user_preferences",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 3,
+ "name": "expose_in_site_notifications",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "f39c5338f0776255c35d13c98e4d4e10bb9a871d420a3315aa8617bb2aa0d679"
+}
diff --git a/apps/labrinth/.sqlx/query-fbd89475ed4a963bfced02d56aec048c797855bbd1e57c18d1f0a5392493c9ec.json b/apps/labrinth/.sqlx/query-fbd89475ed4a963bfced02d56aec048c797855bbd1e57c18d1f0a5392493c9ec.json
new file mode 100644
index 00000000..4a0a5017
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-fbd89475ed4a963bfced02d56aec048c797855bbd1e57c18d1f0a5392493c9ec.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT COUNT(*) \"count!\" FROM users WHERE id = ANY($1)",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "count!",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "fbd89475ed4a963bfced02d56aec048c797855bbd1e57c18d1f0a5392493c9ec"
+}
diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml
index c0dc09a9..fbc7c72c 100644
--- a/apps/labrinth/Cargo.toml
+++ b/apps/labrinth/Cargo.toml
@@ -35,7 +35,12 @@ paste.workspace = true
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
rust-s3.workspace = true
-reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "json", "multipart"] }
+reqwest = { workspace = true, features = [
+ "http2",
+ "rustls-tls-webpki-roots",
+ "json",
+ "multipart",
+] }
hyper-rustls.workspace = true
hyper-util.workspace = true
@@ -85,7 +90,10 @@ sqlx = { workspace = true, features = [
"rust_decimal",
"json",
] }
-rust_decimal = { workspace = true, features = ["serde-with-float", "serde-with-str"] }
+rust_decimal = { workspace = true, features = [
+ "serde-with-float",
+ "serde-with-str",
+] }
redis = { workspace = true, features = ["tokio-comp", "ahash", "r2d2"] }
deadpool-redis.workspace = true
clickhouse = { workspace = true, features = ["uuid", "time"] }
@@ -124,7 +132,12 @@ lettre.workspace = true
rust_iso3166.workspace = true
-async-stripe = { workspace = true, features = ["billing", "checkout", "connect", "webhook-events"] }
+async-stripe = { workspace = true, features = [
+ "billing",
+ "checkout",
+ "connect",
+ "webhook-events",
+] }
rusty-money.workspace = true
json-patch.workspace = true
diff --git a/apps/labrinth/migrations/20250902133943_notification-extension.sql b/apps/labrinth/migrations/20250902133943_notification-extension.sql
new file mode 100644
index 00000000..c5dec787
--- /dev/null
+++ b/apps/labrinth/migrations/20250902133943_notification-extension.sql
@@ -0,0 +1,255 @@
+CREATE TABLE notifications_deliveries (
+ id BIGSERIAL PRIMARY KEY,
+ notification_id BIGINT NOT NULL REFERENCES notifications(id),
+ channel VARCHAR(32) NOT NULL,
+ user_id BIGINT NOT NULL REFERENCES users(id),
+ delivery_priority INTEGER NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ next_attempt timestamptz NOT NULL,
+ attempt_count INTEGER NOT NULL,
+
+ UNIQUE (notification_id, channel)
+);
+
+CREATE INDEX idx_notifications_deliveries_composite_queue
+ON notifications_deliveries(channel, status, next_attempt ASC, delivery_priority DESC)
+INCLUDE (notification_id, user_id);
+
+CREATE INDEX idx_notifications_deliveries_user_id
+ON notifications_deliveries(user_id);
+
+CREATE TABLE users_notifications_preferences (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT REFERENCES users(id),
+ channel VARCHAR(32) NOT NULL,
+ notification_type VARCHAR(32) NOT NULL,
+ enabled BOOL NOT NULL
+);
+
+CREATE INDEX idx_users_notifications_preferences_user_id
+ON users_notifications_preferences(user_id);
+
+CREATE UNIQUE INDEX idx_users_notifications_preferences_partial_contextual_uniq
+ON users_notifications_preferences(COALESCE(user_id, -1), channel, notification_type);
+
+CREATE TABLE notifications_types (
+ name VARCHAR(32) PRIMARY KEY,
+ delivery_priority INTEGER NOT NULL,
+ expose_in_user_preferences BOOL NOT NULL,
+ expose_in_site_notifications BOOL NOT NULL
+);
+
+CREATE TABLE notifications_templates (
+ id BIGSERIAL PRIMARY KEY,
+ channel VARCHAR(32) NOT NULL,
+ notification_type VARCHAR(32) NOT NULL REFERENCES notifications_types(name),
+ subject_line TEXT NOT NULL,
+ body_fetch_url TEXT NOT NULL,
+ plaintext_fallback TEXT NOT NULL
+);
+
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('reset_password', 3, FALSE, FALSE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('project_update', 1, TRUE, TRUE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('team_invite', 1, TRUE, TRUE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('organization_invite', 1, TRUE, TRUE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('status_change', 1, TRUE, TRUE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('moderator_message', 1, TRUE, TRUE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('legacy_markdown', 1, FALSE, TRUE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('unknown', 1, FALSE, TRUE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('verify_email', 3, FALSE, FALSE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('auth_provider_added', 2, FALSE, FALSE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('auth_provider_removed', 2, FALSE, FALSE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('two_factor_enabled', 2, FALSE, FALSE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('two_factor_removed', 2, FALSE, FALSE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('password_changed', 2, FALSE, FALSE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('password_removed', 2, FALSE, FALSE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('email_changed', 2, FALSE, FALSE);
+INSERT INTO notifications_types (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) VALUES ('payment_failed', 2, FALSE, FALSE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'reset_password', TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'project_update', FALSE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'team_invite', FALSE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'organization_invite', FALSE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'status_change', FALSE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'moderator_message', FALSE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'legacy_markdown', FALSE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'unknown', FALSE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'verify_email', TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'auth_provider_added', TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'auth_provider_removed', TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'two_factor_enabled', TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'two_factor_removed', TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'password_changed', TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'password_removed', TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'email_changed', TRUE);
+
+INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
+VALUES (NULL, 'email', 'payment_failed', TRUE);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'reset_password', 'Reset your Modrinth password', 'https://modrinth.com/email/reset-password',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'Please visit the link below to reset your password. If you did not request for your password to be reset, you can safely ignore this email.',
+ CHR(10),
+ 'Reset your password: {resetpassword.url}'
+ )
+);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'verify_email', 'Verify your Modrinth email', 'https://modrinth.com/email/verify-email',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'Please visit the link below to verify your Modrinth email. If the button does not work, you can copy the link and paste it into your browser. This link expires in 24 hours.',
+ CHR(10),
+ 'Verify your email: {verifyemail.url}'
+ )
+);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'auth_provider_added', 'Authentication method added', 'https://modrinth.com/email/auth-provider-added',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'When logging into Modrinth, you can now log in using the ', '{authprovider.name}', ' authentication provider.',
+ CHR(10),
+ 'If you did not make this change, please contact us immediately by replying to this email or through our support portal at https://support.modrinth.com (using',
+ 'the green chat bubble at the bottom of the page)'
+ )
+);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'auth_provider_removed', 'Authentication method removed', 'https://modrinth.com/email/auth-provider-removed',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'When logging into Modrinth, you can no longer log in using the ', '{authprovider.name}', ' authentication provider.',
+ CHR(10),
+ 'If you did not make this change, please contact us immediately by replying to this email or through our support portal at https://support.modrinth.com (using',
+ 'the green chat bubble at the bottom of the page)'
+ )
+);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'two_factor_enabled', 'Two-factor authentication enabled', 'https://modrinth.com/email/two-factor-enabled',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'When logging into Modrinth, you can now enter a code generated by your authenticator app in addition to entering your usual email address and password.',
+ CHR(10),
+ 'If you did not make this change, please contact us immediately by replying to this email or through our support portal at https://support.modrinth.com (using',
+ 'the green chat bubble at the bottom of the page)'
+ )
+);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'two_factor_removed', 'Two-factor authentication removed', 'https://modrinth.com/email/two-factor-removed',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'When logging into Modrinth, you no longer need two-factor authentication to gain access.',
+ CHR(10),
+ 'If you did not make this change, please contact us immediately by replying to this email or through our support portal at https://support.modrinth.com (using',
+ 'the green chat bubble at the bottom of the page)'
+ )
+);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'password_changed', 'Your Modrinth password was changed', 'https://modrinth.com/email/password-changed',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'Your password has been changed on your account.',
+ CHR(10),
+ 'If you did not make this change, please contact us immediately by replying to this email or through our support portal at https://support.modrinth.com (using',
+ 'the green chat bubble at the bottom of the page)'
+ )
+);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'password_removed', 'Your Modrinth password was removed', 'https://modrinth.com/email/password-removed',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'Your password has been removed on your account.',
+ CHR(10),
+ 'If you did not make this change, please contact us immediately by replying to this email or through our support portal at https://support.modrinth.com (using',
+ 'the green chat bubble at the bottom of the page)'
+ )
+);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'email_changed', 'Your Modrinth email was changed', 'https://modrinth.com/email/email-changed',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'Your Modrinth account email has been updated to {emailchanged.new_email}.',
+ CHR(10),
+ 'If you did not make this change, please contact us immediately by replying to this email or through our support portal at https://support.modrinth.com (using',
+ 'the green chat bubble at the bottom of the page)'
+ )
+);
+
+INSERT INTO notifications_templates (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
+VALUES (
+ 'email', 'payment_failed', 'Payment Failed for Modrinth', 'https://modrinth.com/email/payment-failed',
+ CONCAT(
+ 'Hi {user.name},',
+ CHR(10),
+ CHR(10),
+ 'Our attempt to collect payment for {paymentfailed.amount} from the payment card on file was unsuccessful. Please update your billing settings to avoid service termination.',
+ CHR(10),
+ 'Update billing settings: {billing.url}'
+ )
+);
\ No newline at end of file
diff --git a/apps/labrinth/src/auth/email/auth_notif.html b/apps/labrinth/src/auth/email/auth_notif.html
deleted file mode 100644
index d15bc61f..00000000
--- a/apps/labrinth/src/auth/email/auth_notif.html
+++ /dev/null
@@ -1,1635 +0,0 @@
-
-
-
- {{ email_title }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ email_description }}͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
- |
-
-
-
-
-
-
-
-
- |
-
-
-
- |
-
-
-
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ email_title }}
-
-
- |
-
-
-
-
-
- {{ line_one }}
-
-
-
-
-
- {{ line_two }}
-
-
- |
-
-
-
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
- |
-
-
-
-
-
-
-
-
-
- |
-
-
-
- |
-
-
- |
-
- |
-
-
-
-
-
- 410 N Scottsdale Road
-
-
- Suite 1000
-
-
- Tempe, AZ 85281
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
-
-
-
-
-
diff --git a/apps/labrinth/src/auth/email/button_notif.html b/apps/labrinth/src/auth/email/button_notif.html
deleted file mode 100644
index 26ed32a5..00000000
--- a/apps/labrinth/src/auth/email/button_notif.html
+++ /dev/null
@@ -1,1733 +0,0 @@
-
-
-
- {{ email_title }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ email_description }}͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
- |
-
-
-
-
-
-
-
-
- |
-
-
-
- |
-
-
-
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ email_title }}
-
-
- |
-
-
-
-
-
- {{ line_one }}
-
-
-
-
-
- {{ line_two }}
-
-
- |
-
-
- |
-
- |
-
-
- |
-
- |
-
-
-
-
-
- {{ button_link }}
-
-
- |
-
-
-
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
- |
-
-
-
-
-
-
-
-
-
- |
-
-
-
- |
-
-
- |
-
- |
-
-
-
-
-
- 410 N Scottsdale Road
-
-
- Suite 1000
-
-
- Tempe, AZ 85281
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
-
-
-
-
-
diff --git a/apps/labrinth/src/auth/email/mod.rs b/apps/labrinth/src/auth/email/mod.rs
deleted file mode 100644
index 914a7427..00000000
--- a/apps/labrinth/src/auth/email/mod.rs
+++ /dev/null
@@ -1,95 +0,0 @@
-use lettre::message::Mailbox;
-use lettre::message::header::ContentType;
-use lettre::transport::smtp::authentication::Credentials;
-use lettre::transport::smtp::client::{Tls, TlsParameters};
-use lettre::{Message, SmtpTransport, Transport};
-use thiserror::Error;
-use tracing::warn;
-
-#[derive(Error, Debug)]
-pub enum MailError {
- #[error("Environment Error")]
- Env(#[from] dotenvy::Error),
- #[error("Mail Error: {0}")]
- Mail(#[from] lettre::error::Error),
- #[error("Address Parse Error: {0}")]
- Address(#[from] lettre::address::AddressError),
- #[error("SMTP Error: {0}")]
- Smtp(#[from] lettre::transport::smtp::Error),
-}
-
-pub fn send_email_raw(
- to: String,
- subject: String,
- body: String,
-) -> Result<(), MailError> {
- let from_name = dotenvy::var("SMTP_FROM_NAME")
- .unwrap_or_else(|_| "Modrinth".to_string());
- let from_address = dotenvy::var("SMTP_FROM_ADDRESS")
- .unwrap_or_else(|_| "no-reply@mail.modrinth.com".to_string());
-
- let email = Message::builder()
- .from(Mailbox::new(Some(from_name), from_address.parse()?))
- .to(to.parse()?)
- .subject(subject)
- .header(ContentType::TEXT_HTML)
- .body(body)?;
-
- let username = dotenvy::var("SMTP_USERNAME")?;
- let password = dotenvy::var("SMTP_PASSWORD")?;
- let host = dotenvy::var("SMTP_HOST")?;
- let port = dotenvy::var("SMTP_PORT")?.parse::().unwrap_or(465);
- let creds =
- (!username.is_empty()).then(|| Credentials::new(username, password));
- let tls_setting = match dotenvy::var("SMTP_TLS")?.as_str() {
- "none" => Tls::None,
- "opportunistic_start_tls" => {
- Tls::Opportunistic(TlsParameters::new(host.to_string())?)
- }
- "requires_start_tls" => {
- Tls::Required(TlsParameters::new(host.to_string())?)
- }
- "tls" => Tls::Wrapper(TlsParameters::new(host.to_string())?),
- _ => {
- warn!("Unrecognized SMTP TLS setting. Defaulting to TLS.");
- Tls::Wrapper(TlsParameters::new(host.to_string())?)
- }
- };
-
- let mut mailer = SmtpTransport::relay(&host)?.port(port).tls(tls_setting);
- if let Some(creds) = creds {
- mailer = mailer.credentials(creds);
- }
-
- mailer.build().send(&email)?;
-
- Ok(())
-}
-
-pub fn send_email(
- to: String,
- email_title: &str,
- email_description: &str,
- line_two: &str,
- button_info: Option<(&str, &str)>,
-) -> Result<(), MailError> {
- let mut email = if button_info.is_some() {
- include_str!("button_notif.html")
- } else {
- include_str!("auth_notif.html")
- }
- .replace("{{ email_title }}", email_title)
- .replace("{{ email_description }}", email_description)
- .replace("{{ line_one }}", email_description)
- .replace("{{ line_two }}", line_two);
-
- if let Some((button_title, button_link)) = button_info {
- email = email
- .replace("{{ button_title }}", button_title)
- .replace("{{ button_link }}", button_link);
- }
-
- send_email_raw(to, email_title.to_string(), email)?;
-
- Ok(())
-}
diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs
index 051f3833..2dc9311d 100644
--- a/apps/labrinth/src/auth/mod.rs
+++ b/apps/labrinth/src/auth/mod.rs
@@ -1,5 +1,4 @@
pub mod checks;
-pub mod email;
pub mod oauth;
pub mod templates;
pub mod validate;
@@ -8,9 +7,7 @@ pub use checks::{
filter_visible_collections, filter_visible_project_ids,
filter_visible_projects,
};
-pub use email::send_email;
use serde::{Deserialize, Serialize};
-// pub use pat::{generate_pat, PersonalAccessToken};
pub use validate::{check_is_moderator_from_headers, get_user_from_headers};
use crate::file_hosting::FileHostingError;
@@ -36,7 +33,7 @@ pub enum AuthenticationError {
#[error("Error while decoding PAT: {0}")]
Decoding(#[from] ariadne::ids::DecodingError),
#[error("{0}")]
- Mail(#[from] email::MailError),
+ Mail(#[from] crate::queue::email::MailError),
#[error("Invalid Authentication Credentials")]
InvalidCredentials,
#[error("Authentication method was not valid")]
diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs
index 18e0ae46..c79d3d6e 100644
--- a/apps/labrinth/src/background_task.rs
+++ b/apps/labrinth/src/background_task.rs
@@ -1,4 +1,5 @@
use crate::database::redis::RedisPool;
+use crate::queue::email::EmailQueue;
use crate::queue::payouts::{
PayoutsQueue, insert_bank_balances_and_webhook, process_payout,
};
@@ -18,6 +19,7 @@ pub enum BackgroundTask {
IndexBilling,
IndexSubscriptions,
Migrations,
+ Mail,
}
impl BackgroundTask {
@@ -28,6 +30,7 @@ impl BackgroundTask {
search_config: search::SearchConfig,
clickhouse: clickhouse::Client,
stripe_client: stripe::Client,
+ email_queue: EmailQueue,
) {
use BackgroundTask::*;
match self {
@@ -52,10 +55,19 @@ impl BackgroundTask {
)
.await
}
+ Mail => {
+ run_email(email_queue).await;
+ }
}
}
}
+pub async fn run_email(email_queue: EmailQueue) {
+ if let Err(error) = email_queue.index().await {
+ error!(%error, "Failed to index email queue");
+ }
+}
+
pub async fn update_bank_balances(pool: sqlx::Pool) {
let payouts_queue = PayoutsQueue::new();
diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs
index 25f2ff11..7c5b5e60 100644
--- a/apps/labrinth/src/database/models/mod.rs
+++ b/apps/labrinth/src/database/models/mod.rs
@@ -10,6 +10,9 @@ pub mod image_item;
pub mod legacy_loader_fields;
pub mod loader_fields;
pub mod notification_item;
+pub mod notifications_deliveries_item;
+pub mod notifications_template_item;
+pub mod notifications_type_item;
pub mod oauth_client_authorization_item;
pub mod oauth_client_item;
pub mod oauth_token_item;
@@ -26,6 +29,7 @@ pub mod thread_item;
pub mod user_item;
pub mod user_subscription_item;
pub mod users_compliance;
+pub mod users_notifications_preferences_item;
pub mod users_redeemals;
pub mod version_item;
diff --git a/apps/labrinth/src/database/models/notification_item.rs b/apps/labrinth/src/database/models/notification_item.rs
index e50f1ada..eb90b4e9 100644
--- a/apps/labrinth/src/database/models/notification_item.rs
+++ b/apps/labrinth/src/database/models/notification_item.rs
@@ -1,6 +1,8 @@
use super::ids::*;
use crate::database::{models::DatabaseError, redis::RedisPool};
-use crate::models::notifications::NotificationBody;
+use crate::models::notifications::{
+ NotificationBody, NotificationChannel, NotificationDeliveryStatus,
+};
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
@@ -55,6 +57,10 @@ impl NotificationBuilder {
.map(|_| body.clone())
.collect::>();
+ let users_raw_ids = users.iter().map(|x| x.0).collect::>();
+ let notification_ids =
+ notification_ids.iter().map(|x| x.0).collect::>();
+
sqlx::query!(
"
INSERT INTO notifications (
@@ -62,16 +68,97 @@ impl NotificationBuilder {
)
SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::jsonb[])
",
- ¬ification_ids
- .into_iter()
- .map(|x| x.0)
- .collect::>()[..],
- &users.iter().map(|x| x.0).collect::>()[..],
+ ¬ification_ids[..],
+ &users_raw_ids[..],
&bodies[..],
)
.execute(&mut **transaction)
.await?;
+ let notification_types = notification_ids
+ .iter()
+ .map(|_| self.body.notification_type().as_str())
+ .collect::>();
+
+ let notification_channels = NotificationChannel::list()
+ .iter()
+ .map(|x| x.as_str())
+ .collect::>();
+
+ // Insert required rows into `notifications_deliveries` by channel
+ // and notification type, based on the user's preferences.
+ let query = sqlx::query!(
+ r#"
+ WITH
+ channels AS (
+ SELECT channel FROM UNNEST($1::varchar[]) AS t(channel)
+ ),
+ delivery_candidates AS (
+ SELECT
+ ids.notification_id,
+ ids.user_id,
+ channels.channel,
+ nt.delivery_priority,
+ uprefs.enabled user_enabled,
+ dprefs.enabled default_enabled
+ FROM
+ UNNEST(
+ $2::bigint[],
+ $3::bigint[],
+ $4::varchar[]
+ ) AS ids(notification_id, user_id, notification_type)
+ CROSS JOIN channels
+ INNER JOIN
+ notifications_types nt ON nt.name = ids.notification_type
+ LEFT JOIN users_notifications_preferences uprefs
+ ON uprefs.user_id = ids.user_id
+ AND uprefs.channel = channels.channel
+ AND uprefs.notification_type = ids.notification_type
+ LEFT JOIN users_notifications_preferences dprefs
+ ON dprefs.user_id IS NULL
+ AND dprefs.channel = channels.channel
+ AND dprefs.notification_type = ids.notification_type
+ )
+ INSERT INTO notifications_deliveries
+ (notification_id, user_id, channel, delivery_priority, status, next_attempt, attempt_count)
+ SELECT
+ dc.notification_id,
+ dc.user_id,
+ dc.channel,
+ dc.delivery_priority,
+ CASE
+ -- User explicitly enabled
+ WHEN user_enabled = TRUE THEN $5
+
+ -- Is enabled by default, no preference by user
+ WHEN user_enabled IS NULL AND default_enabled = TRUE THEN $5
+
+ -- User explicitly disabled (regardless of default)
+ WHEN user_enabled = FALSE THEN $6
+
+ -- User set no preference, default disabled
+ WHEN user_enabled IS NULL AND default_enabled = FALSE THEN $7
+
+ -- At this point, user set no preference and there is no
+ -- default set, so treat as disabled-by-default.
+ ELSE $7
+ END status,
+ NOW() next_attempt,
+ 0 attempt_count
+ FROM
+ delivery_candidates dc
+ "#,
+ ¬ification_channels[..] as &[&str],
+ ¬ification_ids[..],
+ &users_raw_ids[..],
+ ¬ification_types[..] as &[&str],
+ NotificationDeliveryStatus::Pending.as_str(),
+ NotificationDeliveryStatus::SkippedPreferences.as_str(),
+ NotificationDeliveryStatus::SkippedDefault.as_str(),
+ );
+
+ query.execute(&mut **transaction).await?;
+
DBNotification::clear_user_notifications_cache(&users, redis).await?;
Ok(())
@@ -96,7 +183,7 @@ impl DBNotification {
exec: E,
) -> Result, sqlx::Error>
where
- E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
+ E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let notification_ids_parsed: Vec =
notification_ids.iter().map(|x| x.0).collect();
@@ -144,7 +231,60 @@ impl DBNotification {
.await
}
- pub async fn get_many_user<'a, E>(
+ pub async fn get_all_user<'a, E>(
+ user_id: DBUserId,
+ exec: E,
+ ) -> Result, DatabaseError>
+ where
+ E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
+ {
+ let db_notifications = sqlx::query!(
+ "
+ SELECT n.id, n.user_id, n.name, n.text, n.link, n.created, n.read, n.type notification_type, n.body,
+ JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'name', na.name, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions
+ FROM notifications n
+ LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id
+ WHERE n.user_id = $1
+ GROUP BY n.id, n.user_id
+ ",
+ user_id as DBUserId
+ )
+ .fetch(exec)
+ .map_ok(|row| {
+ let id = DBNotificationId(row.id);
+
+ DBNotification {
+ id,
+ user_id: DBUserId(row.user_id),
+ read: row.read,
+ created: row.created,
+ body: row.body.clone().and_then(|x| serde_json::from_value(x).ok()).unwrap_or_else(|| {
+ if let Some(name) = row.name {
+ NotificationBody::LegacyMarkdown {
+ notification_type: row.notification_type,
+ name,
+ text: row.text.unwrap_or_default(),
+ link: row.link.unwrap_or_default(),
+ actions: serde_json::from_value(
+ row.actions.unwrap_or_default(),
+ )
+ .ok()
+ .unwrap_or_default(),
+ }
+ } else {
+ NotificationBody::Unknown
+ }
+ }),
+ }
+ })
+ .try_collect::>()
+ .await?;
+
+ Ok(db_notifications)
+ }
+
+ /// Returns user notifications that are configured to be exposed on the website.
+ pub async fn get_many_user_exposed_on_site<'a, E>(
user_id: DBUserId,
exec: E,
redis: &RedisPool,
@@ -171,8 +311,10 @@ impl DBNotification {
JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'name', na.name, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions
FROM notifications n
LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id
+ INNER JOIN notifications_types nt on nt.name = n.body ->> 'type'
WHERE n.user_id = $1
- GROUP BY n.id, n.user_id;
+ AND nt.expose_in_site_notifications = TRUE
+ GROUP BY n.id, n.user_id
",
user_id as DBUserId
)
@@ -274,6 +416,16 @@ impl DBNotification {
let notification_ids_parsed: Vec =
notification_ids.iter().map(|x| x.0).collect();
+ sqlx::query!(
+ "
+ DELETE FROM notifications_deliveries
+ WHERE notification_id = ANY($1)
+ ",
+ ¬ification_ids_parsed
+ )
+ .execute(&mut **transaction)
+ .await?;
+
sqlx::query!(
"
DELETE FROM notifications_actions
diff --git a/apps/labrinth/src/database/models/notifications_deliveries_item.rs b/apps/labrinth/src/database/models/notifications_deliveries_item.rs
new file mode 100644
index 00000000..28b67295
--- /dev/null
+++ b/apps/labrinth/src/database/models/notifications_deliveries_item.rs
@@ -0,0 +1,162 @@
+use super::ids::*;
+use crate::database::models::DatabaseError;
+use crate::models::v3::notifications::{
+ NotificationChannel, NotificationDeliveryStatus,
+};
+use chrono::{DateTime, Utc};
+
+pub struct DBNotificationDelivery {
+ pub id: i64,
+ pub notification_id: DBNotificationId,
+ pub user_id: DBUserId,
+ pub channel: NotificationChannel,
+ pub delivery_priority: i32,
+ pub status: NotificationDeliveryStatus,
+ pub next_attempt: DateTime,
+ pub attempt_count: i32,
+}
+
+struct NotificationDeliveryQueryResult {
+ id: i64,
+ notification_id: i64,
+ user_id: i64,
+ channel: String,
+ delivery_priority: i32,
+ status: String,
+ next_attempt: DateTime,
+ attempt_count: i32,
+}
+
+macro_rules! select_notification_deliveries_with_predicate {
+ ($predicate:literal $(, $($param0:expr $(, $param:expr)* $(,)?)?)?) => {
+ sqlx::query_as!(
+ NotificationDeliveryQueryResult,
+ r#"
+ SELECT
+ id, notification_id, user_id, channel, delivery_priority, status, next_attempt, attempt_count
+ FROM notifications_deliveries
+ "#
+ + $predicate
+ $($(, $param0 $(, $param)* )?)?
+ )
+ };
+}
+
+impl From for DBNotificationDelivery {
+ fn from(r: NotificationDeliveryQueryResult) -> Self {
+ DBNotificationDelivery {
+ id: r.id,
+ notification_id: DBNotificationId(r.notification_id),
+ user_id: DBUserId(r.user_id),
+ channel: NotificationChannel::from_str_or_default(&r.channel),
+ delivery_priority: r.delivery_priority,
+ status: NotificationDeliveryStatus::from_str_or_default(&r.status),
+ next_attempt: r.next_attempt,
+ attempt_count: r.attempt_count,
+ }
+ }
+}
+
+impl DBNotificationDelivery {
+ pub async fn get_all_user(
+ user_id: DBUserId,
+ exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result, DatabaseError> {
+ let user_id = user_id.0;
+ let results = select_notification_deliveries_with_predicate!(
+ "WHERE user_id = $1",
+ user_id
+ )
+ .fetch_all(exec)
+ .await?;
+
+ Ok(results.into_iter().map(|r| r.into()).collect())
+ }
+
+ /// Returns deliveries that should be processed next for a given channel using a row-level
+ /// `UPDATE` lock, barring the provided limit.
+ pub async fn lock_channel_processable(
+ channel: NotificationChannel,
+ limit: i64,
+ exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result, DatabaseError> {
+ // This follows the `idx_notifications_deliveries_composite_queue` index.
+ Ok(select_notification_deliveries_with_predicate!(
+ "WHERE
+ status = $3
+ AND channel = $1
+ AND next_attempt <= NOW()
+ ORDER BY
+ delivery_priority DESC,
+ next_attempt ASC
+ LIMIT $2
+ FOR UPDATE
+ SKIP LOCKED
+ ",
+ channel.as_str(),
+ limit,
+ NotificationDeliveryStatus::Pending.as_str()
+ )
+ .fetch_all(exec)
+ .await?
+ .into_iter()
+ .map(Into::into)
+ .collect())
+ }
+
+ /// Inserts the row into the table and updates its ID.
+ pub async fn insert(
+ &mut self,
+ exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result<(), DatabaseError> {
+ let id = sqlx::query_scalar!(
+ "
+ INSERT INTO notifications_deliveries (
+ notification_id, user_id, channel, delivery_priority, status, next_attempt, attempt_count
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING id
+ ",
+ self.notification_id.0,
+ self.user_id.0,
+ self.channel.as_str(),
+ self.delivery_priority,
+ self.status.as_str(),
+ self.next_attempt,
+ self.attempt_count,
+ )
+ .fetch_one(exec)
+ .await?;
+
+ self.id = id;
+
+ Ok(())
+ }
+
+ /// Updates semantically mutable columns of the row.
+ pub async fn update(
+ &self,
+ exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
+ ) -> Result<(), DatabaseError> {
+ sqlx::query!(
+ "
+ UPDATE notifications_deliveries
+ SET
+ delivery_priority = $2,
+ status = $3,
+ next_attempt = $4,
+ attempt_count = $5
+ WHERE id = $1
+ ",
+ self.id,
+ self.delivery_priority,
+ self.status.as_str(),
+ self.next_attempt,
+ self.attempt_count,
+ )
+ .execute(exec)
+ .await?;
+
+ Ok(())
+ }
+}
diff --git a/apps/labrinth/src/database/models/notifications_template_item.rs b/apps/labrinth/src/database/models/notifications_template_item.rs
new file mode 100644
index 00000000..563b4658
--- /dev/null
+++ b/apps/labrinth/src/database/models/notifications_template_item.rs
@@ -0,0 +1,112 @@
+use crate::database::models::DatabaseError;
+use crate::database::redis::RedisPool;
+use crate::models::v3::notifications::{NotificationChannel, NotificationType};
+use serde::{Deserialize, Serialize};
+
+const TEMPLATES_NAMESPACE: &str = "notifications_templates";
+const TEMPLATES_HTML_DATA_NAMESPACE: &str = "notifications_templates_html_data";
+const HTML_DATA_CACHE_EXPIRY: i64 = 60 * 15; // 15 minutes
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct NotificationTemplate {
+ pub id: i64,
+ pub channel: NotificationChannel,
+ pub notification_type: NotificationType,
+ pub subject_line: String,
+ pub body_fetch_url: String,
+ pub plaintext_fallback: String,
+}
+
+struct NotificationTemplateQueryResult {
+ id: i64,
+ channel: String,
+ notification_type: String,
+ subject_line: String,
+ body_fetch_url: String,
+ plaintext_fallback: String,
+}
+
+impl From for NotificationTemplate {
+ fn from(r: NotificationTemplateQueryResult) -> Self {
+ NotificationTemplate {
+ id: r.id,
+ channel: NotificationChannel::from_str_or_default(&r.channel),
+ notification_type: NotificationType::from_str_or_default(
+ &r.notification_type,
+ ),
+ subject_line: r.subject_line,
+ body_fetch_url: r.body_fetch_url,
+ plaintext_fallback: r.plaintext_fallback,
+ }
+ }
+}
+
+impl NotificationTemplate {
+ pub async fn list_channel(
+ channel: NotificationChannel,
+ exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
+ redis: &RedisPool,
+ ) -> Result, DatabaseError> {
+ let mut redis = redis.connect().await?;
+
+ let maybe_cached_templates = redis
+ .get_deserialized_from_json(TEMPLATES_NAMESPACE, channel.as_str())
+ .await?;
+
+ if let Some(cached) = maybe_cached_templates {
+ return Ok(cached);
+ }
+
+ let results = sqlx::query_as!(
+ NotificationTemplateQueryResult,
+ r#"
+ SELECT * FROM notifications_templates WHERE channel = $1
+ "#,
+ channel.as_str(),
+ )
+ .fetch_all(exec)
+ .await?;
+
+ let templates = results.into_iter().map(Into::into).collect();
+
+ redis
+ .set_serialized_to_json(
+ TEMPLATES_NAMESPACE,
+ channel.as_str(),
+ &templates,
+ None,
+ )
+ .await?;
+
+ Ok(templates)
+ }
+
+ pub async fn get_cached_html_data(
+ &self,
+ redis: &RedisPool,
+ ) -> Result