fix(labrinth): retire Sendy for new email newsletter subscriptions (#4073)

* tweak(frontend): do not sign up for the newsletter by default

* fix(labrinth): retire Sendy for new email newsletter subscriptions
This commit is contained in:
Alejandro González 2025-07-29 11:51:50 +02:00 committed by GitHub
parent 175b90be5a
commit c7d0839bfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 95 additions and 70 deletions

View File

@ -218,7 +218,7 @@ const username = ref("");
const password = ref(""); const password = ref("");
const confirmPassword = ref(""); const confirmPassword = ref("");
const token = ref(""); const token = ref("");
const subscribe = ref(true); const subscribe = ref(false);
async function createAccount() { async function createAccount() {
startLoading(); startLoading();

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21\n )\n ", "query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22\n )\n ",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@ -25,10 +25,11 @@
"Text", "Text",
"Text", "Text",
"Text", "Text",
"Bool",
"Bool" "Bool"
] ]
}, },
"nullable": [] "nullable": []
}, },
"hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55" "hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -122,6 +122,11 @@
"ordinal": 23, "ordinal": 23,
"name": "allow_friend_requests", "name": "allow_friend_requests",
"type_info": "Bool" "type_info": "Bool"
},
{
"ordinal": 24,
"name": "is_subscribed_to_newsletter",
"type_info": "Bool"
} }
], ],
"parameters": { "parameters": {
@ -154,8 +159,9 @@
true, true,
true, true,
true, true,
false,
false false
] ]
}, },
"hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0" "hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
} }

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users\n SET is_subscribed_to_newsletter = TRUE\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623"
}

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN is_subscribed_to_newsletter BOOLEAN NOT NULL DEFAULT FALSE;

View File

@ -1,6 +1,6 @@
use super::AuthProvider; use super::AuthProvider;
use crate::auth::AuthenticationError; use crate::auth::AuthenticationError;
use crate::database::models::user_item; use crate::database::models::{DBUser, user_item};
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::models::pats::Scopes; use crate::models::pats::Scopes;
use crate::models::users::User; use crate::models::users::User;
@ -44,17 +44,16 @@ where
Ok(Some((scopes, User::from_full(db_user)))) Ok(Some((scopes, User::from_full(db_user))))
} }
pub async fn get_user_from_headers<'a, E>( pub async fn get_full_user_from_headers<'a, E>(
req: &HttpRequest, req: &HttpRequest,
executor: E, executor: E,
redis: &RedisPool, redis: &RedisPool,
session_queue: &AuthQueue, session_queue: &AuthQueue,
required_scopes: Scopes, required_scopes: Scopes,
) -> Result<(Scopes, User), AuthenticationError> ) -> Result<(Scopes, DBUser), AuthenticationError>
where where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{ {
// Fetch DB user record and minos user from headers
let (scopes, db_user) = get_user_record_from_bearer_token( let (scopes, db_user) = get_user_record_from_bearer_token(
req, req,
None, None,
@ -65,13 +64,33 @@ where
.await? .await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?; .ok_or_else(|| AuthenticationError::InvalidCredentials)?;
let user = User::from_full(db_user);
if !scopes.contains(required_scopes) { if !scopes.contains(required_scopes) {
return Err(AuthenticationError::InvalidCredentials); return Err(AuthenticationError::InvalidCredentials);
} }
Ok((scopes, user)) Ok((scopes, db_user))
}
pub async fn get_user_from_headers<'a, E>(
req: &HttpRequest,
executor: E,
redis: &RedisPool,
session_queue: &AuthQueue,
required_scopes: Scopes,
) -> Result<(Scopes, User), AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let (scopes, db_user) = get_full_user_from_headers(
req,
executor,
redis,
session_queue,
required_scopes,
)
.await?;
Ok((scopes, User::from_full(db_user)))
} }
pub async fn get_user_record_from_bearer_token<'a, 'b, E>( pub async fn get_user_record_from_bearer_token<'a, 'b, E>(

View File

@ -49,6 +49,8 @@ pub struct DBUser {
pub badges: Badges, pub badges: Badges,
pub allow_friend_requests: bool, pub allow_friend_requests: bool,
pub is_subscribed_to_newsletter: bool,
} }
impl DBUser { impl DBUser {
@ -63,13 +65,13 @@ impl DBUser {
avatar_url, raw_avatar_url, bio, created, avatar_url, raw_avatar_url, bio, created,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, paypal_id, paypal_country, paypal_email, email_verified, password, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id, allow_friend_requests venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
) )
VALUES ( VALUES (
$1, $2, $3, $4, $5, $1, $2, $3, $4, $5,
$6, $7, $6, $7,
$8, $9, $10, $11, $12, $13, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21 $14, $15, $16, $17, $18, $19, $20, $21, $22
) )
", ",
self.id as DBUserId, self.id as DBUserId,
@ -93,6 +95,7 @@ impl DBUser {
self.venmo_handle, self.venmo_handle,
self.stripe_customer_id, self.stripe_customer_id,
self.allow_friend_requests, self.allow_friend_requests,
self.is_subscribed_to_newsletter,
) )
.execute(&mut **transaction) .execute(&mut **transaction)
.await?; .await?;
@ -178,7 +181,7 @@ impl DBUser {
created, role, badges, created, role, badges,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email, email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id, allow_friend_requests venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
FROM users FROM users
WHERE id = ANY($1) OR LOWER(username) = ANY($2) WHERE id = ANY($1) OR LOWER(username) = ANY($2)
", ",
@ -212,6 +215,7 @@ impl DBUser {
stripe_customer_id: u.stripe_customer_id, stripe_customer_id: u.stripe_customer_id,
totp_secret: u.totp_secret, totp_secret: u.totp_secret,
allow_friend_requests: u.allow_friend_requests, allow_friend_requests: u.allow_friend_requests,
is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
}; };
acc.insert(u.id, (Some(u.username), user)); acc.insert(u.id, (Some(u.username), user));

View File

@ -1,5 +1,7 @@
use crate::auth::email::send_email; use crate::auth::email::send_email;
use crate::auth::validate::get_user_record_from_bearer_token; use crate::auth::validate::{
get_full_user_from_headers, get_user_record_from_bearer_token,
};
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers}; use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
use crate::database::models::DBUser; use crate::database::models::DBUser;
use crate::database::models::flow_item::DBFlow; use crate::database::models::flow_item::DBFlow;
@ -232,6 +234,7 @@ impl TempUser {
role: Role::Developer.to_string(), role: Role::Developer.to_string(),
badges: Badges::default(), badges: Badges::default(),
allow_friend_requests: true, allow_friend_requests: true,
is_subscribed_to_newsletter: false,
} }
.insert(transaction) .insert(transaction)
.await?; .await?;
@ -1291,37 +1294,6 @@ pub async fn delete_auth_provider(
Ok(HttpResponse::NoContent().finish()) Ok(HttpResponse::NoContent().finish())
} }
pub async fn sign_up_sendy(email: &str) -> Result<(), AuthenticationError> {
let url = dotenvy::var("SENDY_URL")?;
let id = dotenvy::var("SENDY_LIST_ID")?;
let api_key = dotenvy::var("SENDY_API_KEY")?;
let site_url = dotenvy::var("SITE_URL")?;
if url.is_empty() || url == "none" {
tracing::info!("Sendy URL not set, skipping signup");
return Ok(());
}
let mut form = HashMap::new();
form.insert("api_key", &*api_key);
form.insert("email", email);
form.insert("list", &*id);
form.insert("referrer", &*site_url);
let client = reqwest::Client::new();
client
.post(format!("{url}/subscribe"))
.form(&form)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(())
}
pub async fn check_sendy_subscription( pub async fn check_sendy_subscription(
email: &str, email: &str,
) -> Result<bool, AuthenticationError> { ) -> Result<bool, AuthenticationError> {
@ -1456,6 +1428,9 @@ pub async fn create_account_with_password(
role: Role::Developer.to_string(), role: Role::Developer.to_string(),
badges: Badges::default(), badges: Badges::default(),
allow_friend_requests: true, allow_friend_requests: true,
is_subscribed_to_newsletter: new_account
.sign_up_newsletter
.unwrap_or(false),
} }
.insert(&mut transaction) .insert(&mut transaction)
.await?; .await?;
@ -1476,10 +1451,6 @@ pub async fn create_account_with_password(
&format!("Welcome to Modrinth, {}!", new_account.username), &format!("Welcome to Modrinth, {}!", new_account.username),
)?; )?;
if new_account.sign_up_newsletter.unwrap_or(false) {
sign_up_sendy(&new_account.email).await?;
}
transaction.commit().await?; transaction.commit().await?;
Ok(HttpResponse::Ok().json(res)) Ok(HttpResponse::Ok().json(res))
@ -2420,15 +2391,24 @@ pub async fn subscribe_newsletter(
.await? .await?
.1; .1;
if let Some(email) = user.email { sqlx::query!(
sign_up_sendy(&email).await?; "
UPDATE users
SET is_subscribed_to_newsletter = TRUE
WHERE id = $1
",
user.id.0 as i64,
)
.execute(&**pool)
.await?;
Ok(HttpResponse::NoContent().finish()) crate::database::models::DBUser::clear_caches(
} else { &[(user.id.into(), None)],
Err(ApiError::InvalidInput( &redis,
"User does not have an email.".to_string(), )
)) .await?;
}
Ok(HttpResponse::NoContent().finish())
} }
#[get("email/subscribe")] #[get("email/subscribe")]
@ -2438,7 +2418,7 @@ pub async fn get_newsletter_subscription_status(
redis: Data<RedisPool>, redis: Data<RedisPool>,
session_queue: Data<AuthQueue>, session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers( let user = get_full_user_from_headers(
&req, &req,
&**pool, &**pool,
&redis, &redis,
@ -2448,16 +2428,16 @@ pub async fn get_newsletter_subscription_status(
.await? .await?
.1; .1;
if let Some(email) = user.email { let is_subscribed = user.is_subscribed_to_newsletter
let is_subscribed = check_sendy_subscription(&email).await?; || if let Some(email) = user.email {
Ok(HttpResponse::Ok().json(serde_json::json!({ check_sendy_subscription(&email).await?
"subscribed": is_subscribed } else {
}))) false
} else { };
Ok(HttpResponse::Ok().json(serde_json::json!({
"subscribed": false Ok(HttpResponse::Ok().json(serde_json::json!({
}))) "subscribed": is_subscribed
} })))
} }
fn send_email_verify( fn send_email_verify(