feat: email bounce check (#6774)

* feat: neverbounce API client

* feat(labrinth): integrate neverbounce

* chore: tombi

* chore: typo

* chore: clippy

* chore: remove unused dep

* chore: cleanup
This commit is contained in:
François-Xavier Talbot
2026-07-20 10:58:46 +02:00
committed by GitHub
parent 7144f08f0f
commit 6319832465
9 changed files with 406 additions and 24 deletions
+47 -24
View File
@@ -24,6 +24,7 @@ use crate::util::captcha::check_hcaptcha;
use crate::util::error::Context;
use crate::util::ext::get_image_ext;
use crate::util::img::upload_image_optimized;
use crate::util::neverbounce::{check_email, email_check_error_generic};
use crate::util::validate::validation_errors_to_string;
use actix_http::header::LOCATION;
use actix_web::http::StatusCode;
@@ -1453,7 +1454,7 @@ fn validate_account_consent(account_consent: bool) -> Result<(), ApiError> {
Ok(())
}
/// Create account with OAuth.
/// Create account with OAuth.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -1496,6 +1497,10 @@ pub async fn create_oauth_account(
return Err(ApiError::Internal(eyre!("invalid flow kind")));
};
if let Some(email) = &user.email {
ensure_email_is_usable(email).await?;
}
let mut txn = db
.begin()
.await
@@ -1542,7 +1547,7 @@ struct DiscordCommunityHandoffPayload {
nonce: String,
}
/// Link Discord community.
/// Link Discord community.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -1619,7 +1624,7 @@ pub async fn discord_community_link(
Ok(web::Json(DiscordCommunityLinkResponse { url }))
}
/// Remove an auth provider.
/// Remove an auth provider.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -1811,6 +1816,20 @@ impl From<NewAccount> for AccountRegisterFlow {
}
}
async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> {
let result = check_email(email).await.map_err(ApiError::Request)?;
if matches!(
result,
neverbounce::VerificationResult::Invalid
| neverbounce::VerificationResult::Disposable
) {
return Err(ApiError::Request(email_check_error_generic()));
}
Ok(())
}
impl AccountRegisterFlow {
async fn validate(
self,
@@ -1964,7 +1983,7 @@ impl ReadyAccountRegisterFlow {
}
}
/// Validate password account creation.
/// Validate password account creation.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -1992,7 +2011,7 @@ pub async fn validate_create_account_with_password(
Ok(())
}
/// Create account with a password.
/// Create account with a password.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2021,6 +2040,8 @@ pub async fn create_account_with_password(
return Err(ApiError::Turnstile);
}
ensure_email_is_usable(&new_account.email).await?;
let mut transaction = pool.begin().await?;
let ready_flow = AccountRegisterFlow::from(new_account)
@@ -2043,7 +2064,7 @@ pub struct Login {
pub challenge: String,
}
/// Log in with a password.
/// Log in with a password.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2204,7 +2225,7 @@ async fn validate_2fa_code(
}
}
/// Complete login with 2FA.
/// Complete login with 2FA.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2264,7 +2285,7 @@ pub async fn login_2fa(
}
}
/// Start 2FA setup.
/// Start 2FA setup.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2315,7 +2336,7 @@ pub async fn begin_2fa_flow(
}
}
/// Finish 2FA setup.
/// Finish 2FA setup.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2450,7 +2471,7 @@ pub struct Remove2FA {
pub code: String,
}
/// Remove 2FA.
/// Remove 2FA.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2550,7 +2571,7 @@ pub struct ResetPassword {
pub challenge: String,
}
/// Start password reset.
/// Start password reset.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2656,7 +2677,7 @@ pub struct ChangePassword {
pub new_password: Option<String>,
}
/// Change password.
/// Change password.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2822,7 +2843,7 @@ pub struct SetEmail {
pub email: String,
}
/// Set email address.
/// Set email address.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2875,6 +2896,8 @@ pub async fn set_email(
));
}
ensure_email_is_usable(&email_address.email).await?;
let mut transaction = pool.begin().await?;
sqlx::query!(
@@ -2944,7 +2967,7 @@ pub async fn set_email(
Ok(HttpResponse::Ok().finish())
}
/// Resend verification email.
/// Resend verification email.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3019,7 +3042,7 @@ pub struct VerifyEmail {
pub flow: String,
}
/// Verify email address.
/// Verify email address.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3085,7 +3108,7 @@ pub async fn verify_email(
}
}
/// Subscribe to the newsletter.
/// Subscribe to the newsletter.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3134,7 +3157,7 @@ pub async fn subscribe_newsletter(
Ok(HttpResponse::NoContent().finish())
}
/// Get newsletter subscription status.
/// Get newsletter subscription status.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3184,7 +3207,7 @@ pub struct RegisterPasskeyResponse {
pub flow: String,
}
/// Start passkey registration.
/// Start passkey registration.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3284,7 +3307,7 @@ pub struct PasskeyResponse {
pub last_used: Option<chrono::DateTime<Utc>>,
}
/// Finish passkey registration.
/// Finish passkey registration.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3393,7 +3416,7 @@ pub struct AuthenticatePasskeyResponse {
pub flow: String,
}
/// Start passkey authentication.
/// Start passkey authentication.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3436,7 +3459,7 @@ pub struct AuthenticatePasskeyFinish {
pub credential: PublicKeyCredential,
}
/// Finish passkey authentication.
/// Finish passkey authentication.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3553,7 +3576,7 @@ pub async fn authenticate_passkey_finish(
}
}
/// List passkeys.
/// List passkeys.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3603,7 +3626,7 @@ pub struct RenamePasskey {
pub name: String,
}
/// Rename a passkey.
/// Rename a passkey.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3658,7 +3681,7 @@ pub async fn rename_passkey(
Ok(HttpResponse::NoContent().finish())
}
/// Delete a passkey.
/// Delete a passkey.
#[utoipa::path(
context_path = "/auth",
tag = "auth",