use std::sync::Arc;
use async_trait::async_trait;
use sqlx::PgPool;
use tokio::sync::mpsc;
use super::{EmailDelivery, OtpIssue};
use crate::error::{AuthError, Result};
pub const OTP_DELIVERY_QUEUE_CAPACITY: usize = 256;
const STATEMENT_TIMEOUT_MS: u32 = 2_000;
#[async_trait]
pub trait OtpAllowList: Send + Sync {
async fn allows(&self, email: &str) -> Result<bool>;
}
pub struct PgOtpAllowList {
db: PgPool,
statement: String,
}
impl PgOtpAllowList {
#[must_use]
pub const fn new(db: PgPool, statement: String) -> Self {
Self { db, statement }
}
}
#[async_trait]
impl OtpAllowList for PgOtpAllowList {
async fn allows(&self, email: &str) -> Result<bool> {
let db_err = |e: sqlx::Error| AuthError::DatabaseError {
message: e.to_string(),
};
let mut tx = self.db.begin().await.map_err(db_err)?;
sqlx::query("SET TRANSACTION READ ONLY")
.execute(&mut *tx)
.await
.map_err(db_err)?;
sqlx::query(&format!("SET LOCAL statement_timeout = {STATEMENT_TIMEOUT_MS}"))
.execute(&mut *tx)
.await
.map_err(db_err)?;
let row = sqlx::query(&self.statement)
.bind(email)
.fetch_optional(&mut *tx)
.await
.map_err(db_err)?;
Ok(row.is_some())
}
}
struct QueuedOtp {
email: String,
issue: OtpIssue,
}
#[derive(Clone)]
pub struct OtpAllowGate {
list: Arc<dyn OtpAllowList>,
queue: mpsc::Sender<QueuedOtp>,
}
impl OtpAllowGate {
#[must_use]
pub fn new(
list: Arc<dyn OtpAllowList>,
delivery: Arc<dyn EmailDelivery>,
capacity: usize,
) -> Self {
let (queue, mut queued) = mpsc::channel::<QueuedOtp>(capacity);
tokio::spawn(async move {
while let Some(QueuedOtp { email, issue }) = queued.recv().await {
if let Err(e) = delivery.send_otp(&email, &issue).await {
let detail = without_address(&e.to_string(), &email);
tracing::error!(error = %detail, "OTP mail delivery failed");
}
}
});
Self { list, queue }
}
pub(super) async fn admits(&self, email: &str) -> bool {
self.list.allows(email).await.unwrap_or_else(|e| {
let detail = without_address(&e.to_string(), email);
tracing::error!(error = %detail, "otp_allowed_query failed; refusing the address");
false
})
}
pub(super) async fn deliver_if_admitted(&self, email: &str, issue: OtpIssue) {
if !self.admits(email).await {
return;
}
let queued = QueuedOtp {
email: email.to_string(),
issue,
};
if self.queue.try_send(queued).is_err() {
tracing::error!("OTP mail dropped: the delivery queue is full or stopped");
}
}
}
fn without_address(detail: &str, email: &str) -> String {
detail.replace(email, "[address]")
}
#[cfg(test)]
#[path = "allow_list_tests.rs"]
mod tests;