[DO NOT MERGE] Email notification system (#4338)

* Migration

* Fixup db models

* Redis

* Stuff

* Switch PKs to BIGSERIALs, insert to notifications_deliveries when inserting notifications

* Queue, templates

* Query cache

* Fixes, fixtures

* Perf, cache template data & HTML bodies

* Notification type configuration, ResetPassword notification type

* Reset password

* Query cache

* Clippy + fmt

* Traces, fix typo, fix user email in ResetPassword

* send_email

* Models, db

* Remove dead code, adjust notification settings in migration

* Clippy fmt

* Delete dead code, fixes

* Fmt

* Update apps/labrinth/src/queue/email.rs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Remove old fixtures

* Unify email retry delay

* Fix type

* External notifications

* Remove `notifications_types_preference_restrictions`, as user notification preferences is out of scope for this PR

* Query cache, fmt, clippy

* Fix join in get_many_user_exposed_on_site

* Remove migration comment

* Query cache

* Update html body urls

* Remove comment

* Add paymentfailed.service variable to PaymentFailed notification variant

* Fix compile error

* Fix deleting notifications

* Update apps/labrinth/src/database/models/user_item.rs

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Update apps/labrinth/src/database/models/user_item.rs

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Update Cargo.toml

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Update apps/labrinth/migrations/20250902133943_notification-extension.sql

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Address review comments

* Fix compliation

* Update apps/labrinth/src/database/models/users_notifications_preferences_item.rs

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Use strfmt to format emails

* Configurable Reply-To

* Configurable Reply-To

* Refactor for email background task

* Send some emails inline

* Fix account creation email check

* Revert "Use strfmt to format emails"

This reverts commit e0d6614afe51fa6349918377e953ba294c34ae0b.

* Reintroduce fill_template

* Set password reset email inline

* Process more emails per index

* clippy fmt

* Query cache

---------

Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Josiah Glosson <soujournme@gmail.com>
This commit is contained in:
François-Xavier Talbot
2025-09-15 15:02:29 -04:00
committed by GitHub
parent 1491642209
commit 902d749293
51 changed files with 2958 additions and 3652 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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