Compare commits

...
Author SHA1 Message Date
François-X. T. 9f4b433bd5 chore: cleanup 2026-07-19 18:58:12 -04:00
François-X. T. 4612b8ddae chore: remove unused dep 2026-07-19 17:32:11 -04:00
François-X. T. 796456e6bd chore: clippy 2026-07-19 17:06:42 -04:00
François-X. T. b82811843a chore: typo 2026-07-19 17:06:21 -04:00
François-X. T. 47e31db01f chore: tombi 2026-07-19 17:04:27 -04:00
François-X. T. fca4b2135a feat(labrinth): integrate neverbounce 2026-07-19 17:02:59 -04:00
François-X. T. 3340bf368e feat: neverbounce API client 2026-07-19 16:35:59 -04:00
9 changed files with 406 additions and 24 deletions
Generated
+9
View File
@@ -5467,6 +5467,7 @@ dependencies = [
"modrinth-util",
"muralpay",
"murmur2",
"neverbounce",
"paste",
"path-util",
"postcard",
@@ -6247,6 +6248,14 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "neverbounce"
version = "0.1.0"
dependencies = [
"reqwest 0.12.24",
"serde",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
+2
View File
@@ -13,6 +13,7 @@ members = [
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
"packages/neverbounce",
"packages/path-util",
]
@@ -132,6 +133,7 @@ modrinth-util = { path = "packages/modrinth-util" }
muralpay = { path = "packages/muralpay" }
murmur2 = "0.1.0"
native-dialog = "0.9.2"
neverbounce = { path = "packages/neverbounce" }
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false }
+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;
@@ -1440,7 +1441,7 @@ struct NewOAuthAccount {
pub sign_up_newsletter: bool,
}
/// Create account with OAuth.
/// Create account with OAuth.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -1481,6 +1482,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
@@ -1527,7 +1532,7 @@ struct DiscordCommunityHandoffPayload {
nonce: String,
}
/// Link Discord community.
/// Link Discord community.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -1604,7 +1609,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",
@@ -1794,6 +1799,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,
@@ -1947,7 +1966,7 @@ impl ReadyAccountRegisterFlow {
}
}
/// Validate password account creation.
/// Validate password account creation.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -1975,7 +1994,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",
@@ -2002,6 +2021,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)
@@ -2024,7 +2045,7 @@ pub struct Login {
pub challenge: String,
}
/// Log in with a password.
/// Log in with a password.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2185,7 +2206,7 @@ async fn validate_2fa_code(
}
}
/// Complete login with 2FA.
/// Complete login with 2FA.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2245,7 +2266,7 @@ pub async fn login_2fa(
}
}
/// Start 2FA setup.
/// Start 2FA setup.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2296,7 +2317,7 @@ pub async fn begin_2fa_flow(
}
}
/// Finish 2FA setup.
/// Finish 2FA setup.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2431,7 +2452,7 @@ pub struct Remove2FA {
pub code: String,
}
/// Remove 2FA.
/// Remove 2FA.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2531,7 +2552,7 @@ pub struct ResetPassword {
pub challenge: String,
}
/// Start password reset.
/// Start password reset.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2637,7 +2658,7 @@ pub struct ChangePassword {
pub new_password: Option<String>,
}
/// Change password.
/// Change password.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2803,7 +2824,7 @@ pub struct SetEmail {
pub email: String,
}
/// Set email address.
/// Set email address.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -2856,6 +2877,8 @@ pub async fn set_email(
));
}
ensure_email_is_usable(&email_address.email).await?;
let mut transaction = pool.begin().await?;
sqlx::query!(
@@ -2925,7 +2948,7 @@ pub async fn set_email(
Ok(HttpResponse::Ok().finish())
}
/// Resend verification email.
/// Resend verification email.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3000,7 +3023,7 @@ pub struct VerifyEmail {
pub flow: String,
}
/// Verify email address.
/// Verify email address.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3066,7 +3089,7 @@ pub async fn verify_email(
}
}
/// Subscribe to the newsletter.
/// Subscribe to the newsletter.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3115,7 +3138,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",
@@ -3165,7 +3188,7 @@ pub struct RegisterPasskeyResponse {
pub flow: String,
}
/// Start passkey registration.
/// Start passkey registration.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3265,7 +3288,7 @@ pub struct PasskeyResponse {
pub last_used: Option<chrono::DateTime<Utc>>,
}
/// Finish passkey registration.
/// Finish passkey registration.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3374,7 +3397,7 @@ pub struct AuthenticatePasskeyResponse {
pub flow: String,
}
/// Start passkey authentication.
/// Start passkey authentication.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3417,7 +3440,7 @@ pub struct AuthenticatePasskeyFinish {
pub credential: PublicKeyCredential,
}
/// Finish passkey authentication.
/// Finish passkey authentication.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3534,7 +3557,7 @@ pub async fn authenticate_passkey_finish(
}
}
/// List passkeys.
/// List passkeys.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3584,7 +3607,7 @@ pub struct RenamePasskey {
pub name: String,
}
/// Rename a passkey.
/// Rename a passkey.
#[utoipa::path(
context_path = "/auth",
tag = "auth",
@@ -3639,7 +3662,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",
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "neverbounce"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
description = "NeverBounce API client"
repository.workspace = true
license = "MIT"
keywords = []
categories = ["api-bindings"]
[dependencies]
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }
[lints]
workspace = true
+232
View File
@@ -0,0 +1,232 @@
use reqwest::Client;
use serde::{Deserialize, Deserializer, Serialize};
use std::time::Duration;
pub const DEFAULT_API_URL: &str = "https://api.neverbounce.com";
pub const SINGLE_CHECK_PATH: &str = "/v4/single/check";
pub const TIMEOUT: Duration = Duration::from_secs(10);
/// Authentication and email parameters for a single verification.
///
/// The API key is sent in the JSON request body.
#[derive(Clone, Copy)]
pub struct SingleCheckParams<'a> {
pub api_url: &'a str,
pub api_key: &'a str,
pub email: &'a str,
}
impl<'a> SingleCheckParams<'a> {
#[must_use]
pub const fn new(api_key: &'a str, email: &'a str) -> Self {
Self {
api_url: DEFAULT_API_URL,
api_key,
email,
}
}
#[must_use]
pub const fn with_api_url(mut self, api_url: &'a str) -> Self {
self.api_url = api_url;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct SingleCheckResponse {
pub status: ResponseStatus,
#[serde(default)]
pub result: Option<VerificationResult>,
#[serde(default)]
pub flags: Vec<VerificationFlag>,
#[serde(default)]
pub suggested_correction: Option<String>,
#[serde(default)]
pub retry_token: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub execution_time: Option<u64>,
}
impl SingleCheckResponse {
#[must_use]
pub fn is_safe_to_send(&self) -> bool {
matches!(self.status, ResponseStatus::Success)
&& matches!(self.result.as_ref(), Some(VerificationResult::Valid))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ResponseStatus {
Success,
GeneralFailure,
AuthFailure,
TemporarilyUnavailable,
ThrottleTriggered,
BadReferrer,
Unrecognized(String),
}
impl<'de> Deserialize<'de> for ResponseStatus {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match String::deserialize(deserializer)?.as_str() {
"success" => Self::Success,
"general_failure" => Self::GeneralFailure,
"auth_failure" => Self::AuthFailure,
"temp_unavail" => Self::TemporarilyUnavailable,
"throttle_triggered" => Self::ThrottleTriggered,
"bad_referrer" => Self::BadReferrer,
value => Self::Unrecognized(value.to_owned()),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum VerificationResult {
Valid,
Invalid,
Disposable,
CatchAll,
Unknown,
Unrecognized(String),
}
impl VerificationResult {
pub fn as_str(&self) -> &str {
match self {
VerificationResult::Valid => "valid",
VerificationResult::Invalid => "invalid",
VerificationResult::Disposable => "disposable",
VerificationResult::CatchAll => "catchall",
VerificationResult::Unknown => "unknown",
VerificationResult::Unrecognized(other) => other,
}
}
}
impl<'de> Deserialize<'de> for VerificationResult {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match String::deserialize(deserializer)?.as_str() {
"valid" => Self::Valid,
"invalid" => Self::Invalid,
"disposable" => Self::Disposable,
"catchall" => Self::CatchAll,
"unknown" => Self::Unknown,
value => Self::Unrecognized(value.to_owned()),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum VerificationFlag {
HasDns,
HasDnsMx,
BadSyntax,
FreeEmailHost,
Profanity,
RoleAccount,
DisposableEmail,
GovernmentHost,
AcademicHost,
MilitaryHost,
InternationalHost,
SquatterHost,
SpellingMistake,
BadDns,
TemporaryDnsError,
ConnectFails,
AcceptsAll,
ContainsAlias,
ContainsSubdomain,
SmtpConnectable,
SpamtrapNetwork,
HistoricalResponse,
Unrecognized(String),
}
impl<'de> Deserialize<'de> for VerificationFlag {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match String::deserialize(deserializer)?.as_str() {
"has_dns" => Self::HasDns,
"has_dns_mx" => Self::HasDnsMx,
"bad_syntax" => Self::BadSyntax,
"free_email_host" => Self::FreeEmailHost,
"profanity" => Self::Profanity,
"role_account" => Self::RoleAccount,
"disposable_email" => Self::DisposableEmail,
"government_host" => Self::GovernmentHost,
"academic_host" => Self::AcademicHost,
"military_host" => Self::MilitaryHost,
"international_host" => Self::InternationalHost,
"squatter_host" => Self::SquatterHost,
"spelling_mistake" => Self::SpellingMistake,
"bad_dns" => Self::BadDns,
"temporary_dns_error" => Self::TemporaryDnsError,
"connect_fails" => Self::ConnectFails,
"accepts_all" => Self::AcceptsAll,
"contains_alias" => Self::ContainsAlias,
"contains_subdomain" => Self::ContainsSubdomain,
"smtp_connectable" => Self::SmtpConnectable,
"spamtrap_network" => Self::SpamtrapNetwork,
"historical_response" => Self::HistoricalResponse,
value => Self::Unrecognized(value.to_owned()),
})
}
}
#[derive(Serialize)]
struct SingleCheckRequest<'a> {
key: &'a str,
email: &'a str,
timeout: u64,
}
/// Verifies one email address using NeverBounce's single-check endpoint.
///
/// This endpoint should only be called in response to an action such as a form
/// submission. Existing lists and databases must use NeverBounce's bulk API.
/// Both the server verification timeout and the complete HTTP request timeout
/// are ten seconds.
pub async fn single_check(
client: &Client,
params: &SingleCheckParams<'_>,
) -> reqwest::Result<SingleCheckResponse> {
single_check_request(client, params)
.send()
.await?
.error_for_status()?
.json()
.await
}
fn single_check_request(
client: &Client,
params: &SingleCheckParams<'_>,
) -> reqwest::RequestBuilder {
client
.post(format!(
"{}{SINGLE_CHECK_PATH}",
params.api_url.trim_end_matches('/')
))
.timeout(TIMEOUT)
.json(&SingleCheckRequest {
key: params.api_key,
email: params.email,
timeout: TIMEOUT.as_secs(),
})
}