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
+1
View File
@@ -79,6 +79,7 @@ modrinth-content-management = { workspace = true }
modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] }
muralpay = { workspace = true, features = ["client", "mock", "utoipa"] }
murmur2 = { workspace = true }
neverbounce = { workspace = true }
paste = { workspace = true }
path-util = { workspace = true }
postcard = { workspace = true }
+3
View File
@@ -235,6 +235,9 @@ vars! {
SENDY_LIST_ID: String = "none";
SENDY_API_KEY: String = "none";
NEVERBOUNCE_API_KEY: String = "";
NEVERBOUNCE_BASE_URL: String = neverbounce::DEFAULT_API_URL;
CLICKHOUSE_REPLICATED: bool = false;
CLICKHOUSE_URL: String = "http://localhost:8123";
CLICKHOUSE_USER: String = "default";
+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",
+1
View File
@@ -14,6 +14,7 @@ pub mod http;
pub mod img;
pub mod ip;
pub mod kafka;
pub mod neverbounce;
pub mod ratelimit;
pub mod redis;
pub mod routes;
+94
View File
@@ -0,0 +1,94 @@
use std::time::Instant;
use eyre::{WrapErr, eyre};
use neverbounce::{
ResponseStatus, SingleCheckParams, SingleCheckResponse, VerificationResult,
};
use tracing::{debug, error};
use crate::env::ENV;
use crate::util::http::HTTP_CLIENT;
pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
if ENV.NEVERBOUNCE_API_KEY.is_empty() {
debug!(
result = "unknown",
"NeverBounce email check skipped because API key is not set",
);
return Ok(VerificationResult::Unknown);
}
let params = SingleCheckParams::new(&ENV.NEVERBOUNCE_API_KEY, email)
.with_api_url(&ENV.NEVERBOUNCE_BASE_URL);
let check_time_start = Instant::now();
let response = match neverbounce::single_check(&HTTP_CLIENT, &params).await
{
Ok(response) => response,
Err(source) => {
error!(
result = "unknown",
error = ?source,
"NeverBounce email check failed",
);
return Err(eyre!(source)).wrap_err("failed to check email");
}
};
let SingleCheckResponse { status, result, .. } = response;
let check_time = check_time_start.elapsed();
match status {
ResponseStatus::Success => {
let result = result.ok_or_else(|| {
error!(result = "unknown", "NeverBounce email check failed",);
eyre!("")
})?;
if matches!(result, VerificationResult::Unrecognized(_)) {
error!(
result = result.as_str(),
request.time_ms = check_time.as_millis(),
"NeverBounce email check failed",
);
return Err(email_check_error_generic());
}
debug!(
result = result.as_str(),
request.time_ms = check_time.as_millis(),
"NeverBounce email check succeeded",
);
Ok(result)
}
failure_type => {
let result = result.unwrap_or(VerificationResult::Unknown);
error!(
failure_type = response_failure_type(&failure_type),
result = result.as_str(),
request.time_ms = check_time.as_millis(),
"NeverBounce email check failed",
);
Err(email_check_error_generic())
}
}
}
pub fn email_check_error_generic() -> eyre::Error {
eyre!("Please try a different email address!")
}
fn response_failure_type(status: &ResponseStatus) -> &str {
match status {
ResponseStatus::Success => "success",
ResponseStatus::GeneralFailure => "general_failure",
ResponseStatus::AuthFailure => "auth_failure",
ResponseStatus::TemporarilyUnavailable => "temp_unavail",
ResponseStatus::ThrottleTriggered => "throttle_triggered",
ResponseStatus::BadReferrer => "bad_referrer",
ResponseStatus::Unrecognized(status) => status,
_ => "unrecognized",
}
}