[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

@@ -72,6 +72,30 @@ pub enum LegacyNotificationBody {
link: String,
actions: Vec<NotificationAction>,
},
// In `NotificationBody`, this has the `flow` field, however, don't
// include it here, to be 100% certain we don't end up leaking it
// in site notifications.
ResetPassword,
// Idem as ResetPassword
VerifyEmail,
AuthProviderAdded {
provider: String,
},
AuthProviderRemoved {
provider: String,
},
TwoFactorEnabled,
TwoFactorRemoved,
PasswordChanged,
PasswordRemoved,
EmailChanged {
new_email: String,
to_email: String,
},
PaymentFailed {
amount: String,
service: String,
},
Unknown,
}
@@ -93,6 +117,36 @@ impl LegacyNotification {
NotificationBody::ModeratorMessage { .. } => {
Some("moderator_message".to_string())
}
NotificationBody::ResetPassword { .. } => {
Some("reset_password".to_string())
}
NotificationBody::VerifyEmail { .. } => {
Some("verify_email".to_string())
}
NotificationBody::AuthProviderAdded { .. } => {
Some("auth_provider_added".to_string())
}
NotificationBody::AuthProviderRemoved { .. } => {
Some("auth_provider_removed".to_string())
}
NotificationBody::TwoFactorEnabled => {
Some("two_factor_enabled".to_string())
}
NotificationBody::TwoFactorRemoved => {
Some("two_factor_removed".to_string())
}
NotificationBody::PasswordChanged => {
Some("password_changed".to_string())
}
NotificationBody::PasswordRemoved => {
Some("password_removed".to_string())
}
NotificationBody::EmailChanged { .. } => {
Some("email_changed".to_string())
}
NotificationBody::PaymentFailed { .. } => {
Some("payment_failed".to_string())
}
NotificationBody::LegacyMarkdown {
notification_type, ..
} => notification_type.clone(),
@@ -162,6 +216,40 @@ impl LegacyNotification {
link,
actions,
},
NotificationBody::ResetPassword { .. } => {
LegacyNotificationBody::ResetPassword
}
NotificationBody::VerifyEmail { .. } => {
LegacyNotificationBody::VerifyEmail
}
NotificationBody::AuthProviderAdded { provider } => {
LegacyNotificationBody::AuthProviderAdded { provider }
}
NotificationBody::AuthProviderRemoved { provider } => {
LegacyNotificationBody::AuthProviderRemoved { provider }
}
NotificationBody::TwoFactorEnabled => {
LegacyNotificationBody::TwoFactorEnabled
}
NotificationBody::TwoFactorRemoved => {
LegacyNotificationBody::TwoFactorRemoved
}
NotificationBody::PasswordChanged => {
LegacyNotificationBody::PasswordChanged
}
NotificationBody::PasswordRemoved => {
LegacyNotificationBody::PasswordRemoved
}
NotificationBody::EmailChanged {
new_email,
to_email,
} => LegacyNotificationBody::EmailChanged {
new_email,
to_email,
},
NotificationBody::PaymentFailed { amount, service } => {
LegacyNotificationBody::PaymentFailed { amount, service }
}
NotificationBody::Unknown => LegacyNotificationBody::Unknown,
};

View File

@@ -33,6 +33,20 @@ pub enum ProductMetadata {
},
}
impl ProductMetadata {
pub fn is_pyro(&self) -> bool {
matches!(self, ProductMetadata::Pyro { .. })
}
pub fn is_medal(&self) -> bool {
matches!(self, ProductMetadata::Medal { .. })
}
pub fn is_midas(&self) -> bool {
matches!(self, ProductMetadata::Midas)
}
}
#[derive(Serialize, Deserialize)]
pub struct ProductPrice {
pub id: ProductPriceId,

View File

@@ -1,11 +1,13 @@
use super::ids::OrganizationId;
use super::ids::*;
use crate::database::models::notification_item::DBNotification;
use crate::database::models::notification_item::DBNotificationAction;
use crate::database::models::notifications_deliveries_item::DBNotificationDelivery;
use crate::models::ids::{
NotificationId, ProjectId, ReportId, TeamId, ThreadId, ThreadMessageId,
VersionId,
};
use crate::models::projects::ProjectStatus;
use crate::routes::ApiError;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -24,6 +26,76 @@ pub struct Notification {
pub actions: Vec<NotificationAction>,
}
#[derive(Serialize, Deserialize, Clone, Copy, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum NotificationType {
// If adding a notification type, add a variant in `NotificationBody` of the same name!
ProjectUpdate,
TeamInvite,
OrganizationInvite,
StatusChange,
ModeratorMessage,
LegacyMarkdown,
ResetPassword,
VerifyEmail,
AuthProviderAdded,
AuthProviderRemoved,
TwoFactorEnabled,
TwoFactorRemoved,
PasswordChanged,
PasswordRemoved,
EmailChanged,
PaymentFailed,
Unknown,
}
impl NotificationType {
pub fn as_str(self) -> &'static str {
match self {
NotificationType::ProjectUpdate => "project_update",
NotificationType::TeamInvite => "team_invite",
NotificationType::OrganizationInvite => "organization_invite",
NotificationType::StatusChange => "status_change",
NotificationType::ModeratorMessage => "moderator_message",
NotificationType::LegacyMarkdown => "legacy_markdown",
NotificationType::ResetPassword => "reset_password",
NotificationType::VerifyEmail => "verify_email",
NotificationType::AuthProviderAdded => "auth_provider_added",
NotificationType::AuthProviderRemoved => "auth_provider_removed",
NotificationType::TwoFactorEnabled => "two_factor_enabled",
NotificationType::TwoFactorRemoved => "two_factor_removed",
NotificationType::PasswordChanged => "password_changed",
NotificationType::PasswordRemoved => "password_removed",
NotificationType::EmailChanged => "email_changed",
NotificationType::PaymentFailed => "payment_failed",
NotificationType::Unknown => "unknown",
}
}
pub fn from_str_or_default(s: &str) -> Self {
match s {
"project_update" => NotificationType::ProjectUpdate,
"team_invite" => NotificationType::TeamInvite,
"organization_invite" => NotificationType::OrganizationInvite,
"status_change" => NotificationType::StatusChange,
"moderator_message" => NotificationType::ModeratorMessage,
"legacy_markdown" => NotificationType::LegacyMarkdown,
"reset_password" => NotificationType::ResetPassword,
"verify_email" => NotificationType::VerifyEmail,
"auth_provider_added" => NotificationType::AuthProviderAdded,
"auth_provider_removed" => NotificationType::AuthProviderRemoved,
"two_factor_enabled" => NotificationType::TwoFactorEnabled,
"two_factor_removed" => NotificationType::TwoFactorRemoved,
"password_changed" => NotificationType::PasswordChanged,
"password_removed" => NotificationType::PasswordRemoved,
"email_changed" => NotificationType::EmailChanged,
"payment_failed" => NotificationType::PaymentFailed,
"unknown" => NotificationType::Unknown,
_ => NotificationType::Unknown,
}
}
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NotificationBody {
@@ -62,9 +134,87 @@ pub enum NotificationBody {
link: String,
actions: Vec<NotificationAction>,
},
ResetPassword {
flow: String,
},
VerifyEmail {
flow: String,
},
AuthProviderAdded {
provider: String,
},
AuthProviderRemoved {
provider: String,
},
TwoFactorEnabled,
TwoFactorRemoved,
PasswordChanged,
PasswordRemoved,
EmailChanged {
new_email: String,
to_email: String,
},
PaymentFailed {
amount: String,
service: String,
},
Unknown,
}
impl NotificationBody {
pub fn notification_type(&self) -> NotificationType {
match &self {
NotificationBody::ProjectUpdate { .. } => {
NotificationType::ProjectUpdate
}
NotificationBody::TeamInvite { .. } => NotificationType::TeamInvite,
NotificationBody::OrganizationInvite { .. } => {
NotificationType::OrganizationInvite
}
NotificationBody::StatusChange { .. } => {
NotificationType::StatusChange
}
NotificationBody::ModeratorMessage { .. } => {
NotificationType::ModeratorMessage
}
NotificationBody::LegacyMarkdown { .. } => {
NotificationType::LegacyMarkdown
}
NotificationBody::ResetPassword { .. } => {
NotificationType::ResetPassword
}
NotificationBody::VerifyEmail { .. } => {
NotificationType::VerifyEmail
}
NotificationBody::AuthProviderAdded { .. } => {
NotificationType::AuthProviderAdded
}
NotificationBody::AuthProviderRemoved { .. } => {
NotificationType::AuthProviderRemoved
}
NotificationBody::TwoFactorEnabled => {
NotificationType::TwoFactorEnabled
}
NotificationBody::TwoFactorRemoved => {
NotificationType::TwoFactorRemoved
}
NotificationBody::PasswordChanged => {
NotificationType::PasswordChanged
}
NotificationBody::PasswordRemoved => {
NotificationType::PasswordRemoved
}
NotificationBody::EmailChanged { .. } => {
NotificationType::EmailChanged
}
NotificationBody::PaymentFailed { .. } => {
NotificationType::PaymentFailed
}
NotificationBody::Unknown => NotificationType::Unknown,
}
}
}
impl From<DBNotification> for Notification {
fn from(notif: DBNotification) -> Self {
let (name, text, link, actions) = {
@@ -173,6 +323,13 @@ impl From<DBNotification> for Notification {
},
vec![],
),
// Don't expose the `flow` field
NotificationBody::ResetPassword { .. } => (
"Password reset requested".to_string(),
"You've requested to reset your password. Please check your email for a reset link.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::LegacyMarkdown {
name,
text,
@@ -185,6 +342,64 @@ impl From<DBNotification> for Notification {
link.clone(),
actions.clone().into_iter().collect(),
),
// The notifications from here to down below are listed with messages for completeness' sake,
// though they should never be sent via site notifications. This should be disabled via database
// options. Messages should be reviewed and worded better if we want to distribute these notifications
// via the site.
NotificationBody::PaymentFailed { .. } => (
"Payment failed".to_string(),
"A payment on your account failed. Please update your billing information.".to_string(),
"/settings/billing".to_string(),
vec![],
),
NotificationBody::VerifyEmail { .. } => (
"Verify your email".to_string(),
"You've requested to verify your email. Please check your email for a verification link.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::AuthProviderAdded { .. } => (
"Auth provider added".to_string(),
"You've added a new authentication provider to your account.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::AuthProviderRemoved { .. } => (
"Auth provider removed".to_string(),
"You've removed a authentication provider from your account.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::TwoFactorEnabled => (
"Two-factor authentication enabled".to_string(),
"You've enabled two-factor authentication on your account.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::TwoFactorRemoved => (
"Two-factor authentication removed".to_string(),
"You've removed two-factor authentication from your account.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::PasswordChanged => (
"Password changed".to_string(),
"You've changed your account password.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::PasswordRemoved => (
"Password removed".to_string(),
"You've removed your account password.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::EmailChanged { .. } => (
"Email changed".to_string(),
"Your account email was changed.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::Unknown => {
("".to_string(), "".to_string(), "#".to_string(), vec![])
}
@@ -221,3 +436,104 @@ impl From<DBNotificationAction> for NotificationAction {
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationChannel {
Email,
}
impl NotificationChannel {
pub fn list() -> &'static [Self] {
&[NotificationChannel::Email]
}
pub fn as_str(self) -> &'static str {
match self {
NotificationChannel::Email => "email",
}
}
pub fn from_str_or_default(s: &str) -> Self {
match s {
"email" => NotificationChannel::Email,
_ => NotificationChannel::Email,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NotificationDeliveryStatus {
Pending,
SkippedPreferences,
SkippedDefault,
Delivered,
PermanentlyFailed,
}
impl NotificationDeliveryStatus {
pub fn as_user_error(self) -> Result<(), ApiError> {
match self {
NotificationDeliveryStatus::Delivered => Ok(()),
NotificationDeliveryStatus::SkippedPreferences |
NotificationDeliveryStatus::SkippedDefault |
NotificationDeliveryStatus::Pending => Err(ApiError::InvalidInput("An error occured while sending an email to your email address. Please try again later.".to_owned())),
NotificationDeliveryStatus::PermanentlyFailed => Err(ApiError::InvalidInput("This email address doesn't exist! Please try another one.".to_owned())),
}
}
pub fn as_str(self) -> &'static str {
match self {
NotificationDeliveryStatus::Pending => "pending",
NotificationDeliveryStatus::SkippedPreferences => {
"skipped_preferences"
}
NotificationDeliveryStatus::SkippedDefault => "skipped_default",
NotificationDeliveryStatus::Delivered => "delivered",
NotificationDeliveryStatus::PermanentlyFailed => {
"permanently_failed"
}
}
}
pub fn from_str_or_default(s: &str) -> Self {
match s {
"pending" => NotificationDeliveryStatus::Pending,
"skipped_preferences" => {
NotificationDeliveryStatus::SkippedPreferences
}
"skipped_default" => NotificationDeliveryStatus::SkippedDefault,
"delivered" => NotificationDeliveryStatus::Delivered,
"permanently_failed" => {
NotificationDeliveryStatus::PermanentlyFailed
}
_ => NotificationDeliveryStatus::Pending,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct NotificationDelivery {
pub notification_id: NotificationId,
pub user_id: UserId,
pub channel: NotificationChannel,
pub delivery_priority: i32,
pub status: NotificationDeliveryStatus,
pub next_attempt: DateTime<Utc>,
pub attempt_count: i32,
}
impl From<DBNotificationDelivery> for NotificationDelivery {
fn from(delivery: DBNotificationDelivery) -> Self {
Self {
notification_id: delivery.notification_id.into(),
user_id: delivery.user_id.into(),
channel: delivery.channel,
delivery_priority: delivery.delivery_priority,
status: delivery.status,
next_attempt: delivery.next_attempt,
attempt_count: delivery.attempt_count,
}
}
}