You've already forked AstralRinth
forked from didirus/AstralRinth
[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:
committed by
GitHub
parent
1491642209
commit
902d749293
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user