feat: add email verification & password reset link
This commit is contained in:
parent
440bf12c43
commit
d24f850882
20 changed files with 928 additions and 62 deletions
|
|
@ -25,3 +25,4 @@ axum-login = "0.18"
|
|||
argon2 = "0.5"
|
||||
time = "0.3"
|
||||
thiserror = "1"
|
||||
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1", "builder", "hostname"] }
|
||||
|
|
|
|||
12
server/relay-server/migrations/003_email_verification.sql
Normal file
12
server/relay-server/migrations/003_email_verification.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);
|
||||
|
|
@ -30,7 +30,8 @@ impl AuthUser for db::User {
|
|||
|
||||
#[derive(Clone)]
|
||||
pub struct Credentials {
|
||||
pub username: String,
|
||||
/// Accepts either a username or an email address.
|
||||
pub login: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +67,7 @@ impl AuthnBackend for AuthBackend {
|
|||
&self,
|
||||
creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let Some(user) = db::get_user_by_username(&self.pool, &creds.username).await? else {
|
||||
let Some(user) = db::get_user_by_username_or_email(&self.pool, &creds.login).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub struct User {
|
|||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub created_at: i64,
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
/// Aggregated game statistics for a user's public profile.
|
||||
|
|
@ -54,7 +55,7 @@ impl DbError {
|
|||
}
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
pub fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
|
|
@ -83,12 +84,27 @@ pub async fn init_db(url: &str) -> Pool {
|
|||
.batch_execute(include_str!("../migrations/002_participants_unique.sql"))
|
||||
.await
|
||||
.expect("Migration 002 failed");
|
||||
client
|
||||
.batch_execute(include_str!("../migrations/003_email_verification.sql"))
|
||||
.await
|
||||
.expect("Migration 003 failed");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn user_from_row(r: &tokio_postgres::Row) -> User {
|
||||
User {
|
||||
id: r.get("id"),
|
||||
username: r.get("username"),
|
||||
email: r.get("email"),
|
||||
password_hash: r.get("password_hash"),
|
||||
created_at: r.get("created_at"),
|
||||
email_verified: r.get("email_verified"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
pool: &Pool,
|
||||
username: &str,
|
||||
|
|
@ -98,8 +114,8 @@ pub async fn create_user(
|
|||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_one(
|
||||
"INSERT INTO users (username, email, password_hash, created_at) \
|
||||
VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
"INSERT INTO users (username, email, password_hash, created_at, email_verified) \
|
||||
VALUES ($1, $2, $3, $4, FALSE) RETURNING id",
|
||||
&[&username, &email, &password_hash, &now_unix()],
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -110,33 +126,123 @@ pub async fn get_user_by_id(pool: &Pool, id: i64) -> Result<Option<User>, DbErro
|
|||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
|
||||
"SELECT id, username, email, password_hash, created_at, email_verified \
|
||||
FROM users WHERE id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.map(|r| User {
|
||||
id: r.get("id"),
|
||||
username: r.get("username"),
|
||||
email: r.get("email"),
|
||||
password_hash: r.get("password_hash"),
|
||||
created_at: r.get("created_at"),
|
||||
}))
|
||||
Ok(row.as_ref().map(user_from_row))
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(pool: &Pool, username: &str) -> Result<Option<User>, DbError> {
|
||||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",
|
||||
"SELECT id, username, email, password_hash, created_at, email_verified \
|
||||
FROM users WHERE username = $1",
|
||||
&[&username],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.map(|r| User {
|
||||
id: r.get("id"),
|
||||
username: r.get("username"),
|
||||
email: r.get("email"),
|
||||
password_hash: r.get("password_hash"),
|
||||
created_at: r.get("created_at"),
|
||||
Ok(row.as_ref().map(user_from_row))
|
||||
}
|
||||
|
||||
pub async fn get_user_by_email(pool: &Pool, email: &str) -> Result<Option<User>, DbError> {
|
||||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT id, username, email, password_hash, created_at, email_verified \
|
||||
FROM users WHERE email = $1",
|
||||
&[&email],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.as_ref().map(user_from_row))
|
||||
}
|
||||
|
||||
/// Looks up a user by username first; if not found, tries by email.
|
||||
pub async fn get_user_by_username_or_email(pool: &Pool, login: &str) -> Result<Option<User>, DbError> {
|
||||
if let Some(u) = get_user_by_username(pool, login).await? {
|
||||
return Ok(Some(u));
|
||||
}
|
||||
get_user_by_email(pool, login).await
|
||||
}
|
||||
|
||||
pub async fn set_email_verified(pool: &Pool, user_id: i64) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"UPDATE users SET email_verified = TRUE WHERE id = $1",
|
||||
&[&user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"UPDATE users SET password_hash = $1 WHERE id = $2",
|
||||
&[&hash, &user_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Email tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_email_token(
|
||||
pool: &Pool,
|
||||
user_id: i64,
|
||||
token: &str,
|
||||
kind: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO email_tokens (user_id, token, kind, expires_at, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
&[&user_id, &token, &kind, &expires_at, &now_unix()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes all tokens of the given kind for a user (call before creating a fresh one).
|
||||
pub async fn delete_email_tokens(pool: &Pool, user_id: i64, kind: &str) -> Result<(), DbError> {
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
.execute(
|
||||
"DELETE FROM email_tokens WHERE user_id = $1 AND kind = $2",
|
||||
&[&user_id, &kind],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically deletes the token row and returns the `user_id` if the token
|
||||
/// exists and has not expired. Returns `None` for missing or expired tokens.
|
||||
pub async fn consume_email_token(
|
||||
pool: &Pool,
|
||||
token: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<i64>, DbError> {
|
||||
let client = pool.get().await?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"DELETE FROM email_tokens WHERE token = $1 AND kind = $2 \
|
||||
RETURNING user_id, expires_at",
|
||||
&[&token, &kind],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(row.and_then(|r| {
|
||||
let expires_at: i64 = r.get("expires_at");
|
||||
if expires_at >= now_unix() {
|
||||
Some(r.get("user_id"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
//! HTTP endpoints for user management (Phases 2 & 4).
|
||||
//! HTTP endpoints for user management.
|
||||
//!
|
||||
//! Routes:
|
||||
//! POST /auth/register
|
||||
//! POST /auth/login
|
||||
//! POST /auth/logout
|
||||
//! GET /auth/me
|
||||
//! GET /auth/verify-email?token=…
|
||||
//! POST /auth/resend-verification
|
||||
//! POST /auth/forgot-password
|
||||
//! POST /auth/reset-password
|
||||
//! GET /users/:username
|
||||
//! GET /users/:username/games?page=0&per_page=20
|
||||
//! GET /games/:id
|
||||
//! POST /games/result
|
||||
|
||||
use axum::{
|
||||
|
|
@ -17,15 +22,20 @@ use axum::{
|
|||
routing::{get, post},
|
||||
};
|
||||
use axum_login::AuthSession;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::{AuthBackend, Credentials, hash_password};
|
||||
use crate::db;
|
||||
use crate::db::{self, now_unix};
|
||||
use crate::lobby::AppState;
|
||||
|
||||
const VERIFY_TOKEN_EXPIRY: i64 = 86_400; // 24 hours
|
||||
const RESET_TOKEN_EXPIRY: i64 = 3_600; // 1 hour
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
|
|
@ -34,12 +44,26 @@ pub fn router() -> Router<Arc<AppState>> {
|
|||
.route("/auth/login", post(login))
|
||||
.route("/auth/logout", post(logout))
|
||||
.route("/auth/me", get(me))
|
||||
.route("/auth/verify-email", get(verify_email))
|
||||
.route("/auth/resend-verification", post(resend_verification))
|
||||
.route("/auth/forgot-password", post(forgot_password))
|
||||
.route("/auth/reset-password", post(reset_password))
|
||||
.route("/users/{username}", get(user_profile))
|
||||
.route("/users/{username}/games", get(user_games))
|
||||
.route("/games/result", post(game_result))
|
||||
.route("/games/{id}", get(game_detail))
|
||||
}
|
||||
|
||||
// ── Token generation ──────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_token() -> String {
|
||||
rand::thread_rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(64)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Error type ────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AppError {
|
||||
|
|
@ -88,10 +112,27 @@ struct LoginBody {
|
|||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenQuery {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ForgotPasswordBody {
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResetPasswordBody {
|
||||
token: String,
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MeResponse {
|
||||
id: i64,
|
||||
username: String,
|
||||
email_verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -147,7 +188,7 @@ impl From<db::GameSummary> for GameSummaryResponse {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
// ── Auth handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn register(
|
||||
mut auth_session: AuthSession<AuthBackend>,
|
||||
|
|
@ -180,6 +221,16 @@ async fn register(
|
|||
.await?
|
||||
.ok_or(AppError::Internal)?;
|
||||
|
||||
// Send verification email (best-effort).
|
||||
let token = generate_token();
|
||||
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
|
||||
if db::create_email_token(&state.db, user_id, &token, "verify", expires_at)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
state.mailer.send_verification(&body.email, &token).await;
|
||||
}
|
||||
|
||||
auth_session.login(&user).await.map_err(|_| AppError::Internal)?;
|
||||
|
||||
Ok((
|
||||
|
|
@ -187,6 +238,7 @@ async fn register(
|
|||
Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email_verified: user.email_verified,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
|
@ -196,7 +248,7 @@ async fn login(
|
|||
Json(body): Json<LoginBody>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let creds = Credentials {
|
||||
username: body.username,
|
||||
login: body.username,
|
||||
password: body.password,
|
||||
};
|
||||
|
||||
|
|
@ -211,6 +263,7 @@ async fn login(
|
|||
Ok(Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email_verified: user.email_verified,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -224,12 +277,86 @@ async fn me(auth_session: AuthSession<AuthBackend>) -> Result<impl IntoResponse,
|
|||
Some(user) => Ok(Json(MeResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email_verified: user.email_verified,
|
||||
})
|
||||
.into_response()),
|
||||
None => Ok(StatusCode::UNAUTHORIZED.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_email(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<TokenQuery>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let user_id = db::consume_email_token(&state.db, ¶ms.token, "verify")
|
||||
.await?
|
||||
.ok_or(AppError::BadRequest("invalid or expired token"))?;
|
||||
|
||||
db::set_email_verified(&state.db, user_id).await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn resend_verification(
|
||||
auth_session: AuthSession<AuthBackend>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let user = auth_session.user.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
if user.email_verified {
|
||||
return Ok(StatusCode::OK);
|
||||
}
|
||||
|
||||
db::delete_email_tokens(&state.db, user.id, "verify").await?;
|
||||
|
||||
let token = generate_token();
|
||||
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
|
||||
db::create_email_token(&state.db, user.id, &token, "verify", expires_at).await?;
|
||||
|
||||
state.mailer.send_verification(&user.email, &token).await;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn forgot_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ForgotPasswordBody>,
|
||||
) -> StatusCode {
|
||||
// Always return 200 to avoid leaking which email addresses are registered.
|
||||
if let Ok(Some(user)) = db::get_user_by_email(&state.db, &body.email).await {
|
||||
let _ = db::delete_email_tokens(&state.db, user.id, "reset").await;
|
||||
let token = generate_token();
|
||||
let expires_at = now_unix() + RESET_TOKEN_EXPIRY;
|
||||
if db::create_email_token(&state.db, user.id, &token, "reset", expires_at)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
state.mailer.send_password_reset(&body.email, &token).await;
|
||||
}
|
||||
}
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
async fn reset_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<ResetPasswordBody>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
if body.new_password.len() < 8 {
|
||||
return Err(AppError::BadRequest("password must be at least 8 characters"));
|
||||
}
|
||||
|
||||
let user_id = db::consume_email_token(&state.db, &body.token, "reset")
|
||||
.await?
|
||||
.ok_or(AppError::BadRequest("invalid or expired token"))?;
|
||||
|
||||
let hash = hash_password(&body.new_password).map_err(|_| AppError::Internal)?;
|
||||
db::update_password_hash(&state.db, user_id, &hash).await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// ── Profile handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn user_profile(
|
||||
Path(username): Path<String>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
|
@ -270,7 +397,7 @@ async fn user_games(
|
|||
}))
|
||||
}
|
||||
|
||||
// ── Game detail (Phase 5) ─────────────────────────────────────────────────────
|
||||
// ── Game detail ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ParticipantWithUsername {
|
||||
|
|
@ -338,7 +465,7 @@ async fn game_detail(
|
|||
}))
|
||||
}
|
||||
|
||||
// ── Game result recording (Phase 4) ──────────────────────────────────────────
|
||||
// ── Game result recording ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GameResultBody {
|
||||
|
|
@ -368,7 +495,6 @@ async fn game_result(
|
|||
) -> Result<impl IntoResponse, AppError> {
|
||||
let compound_id = format!("{}#{}", body.room_code, body.game_id);
|
||||
|
||||
// Snapshot the fields we need while holding the lock, then release immediately.
|
||||
let (game_record_id, user_ids) = {
|
||||
let rooms = state.rooms.lock().await;
|
||||
let room = rooms.get(&compound_id).ok_or(AppError::NotFound)?;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ use tokio::fs;
|
|||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::smtp::Mailer;
|
||||
|
||||
/// The game entry we have for one game.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GameEntry {
|
||||
|
|
@ -59,14 +61,17 @@ pub struct AppState {
|
|||
pub configs: RwLock<HashMap<String, u16>>,
|
||||
/// PostgreSQL connection pool — shared across all request handlers.
|
||||
pub db: Pool,
|
||||
/// SMTP mailer for email verification and password reset.
|
||||
pub mailer: Mailer,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: Pool) -> Self {
|
||||
pub fn new(db: Pool, mailer: Mailer) -> Self {
|
||||
Self {
|
||||
rooms: Mutex::new(HashMap::new()),
|
||||
configs: RwLock::new(HashMap::new()),
|
||||
db,
|
||||
mailer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ mod hand_shake;
|
|||
mod http;
|
||||
mod lobby;
|
||||
mod message_relay;
|
||||
mod smtp;
|
||||
|
||||
use crate::auth::AuthBackend;
|
||||
use crate::hand_shake::{
|
||||
|
|
@ -55,6 +56,8 @@ async fn main() {
|
|||
.unwrap_or_else(|_| "postgresql://trictrac:trictrac@127.0.0.1:5432/trictrac".to_string());
|
||||
let pool = db::init_db(&database_url).await;
|
||||
|
||||
let mailer = smtp::Mailer::from_env();
|
||||
|
||||
let session_store = MemoryStore::default();
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
|
|
@ -63,7 +66,7 @@ async fn main() {
|
|||
let auth_backend = AuthBackend::new(pool.clone());
|
||||
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
|
||||
|
||||
let app_state = Arc::new(AppState::new(pool));
|
||||
let app_state = Arc::new(AppState::new(pool, mailer));
|
||||
let watchdog_state = app_state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min
|
||||
|
|
|
|||
99
server/relay-server/src/smtp.rs
Normal file
99
server/relay-server/src/smtp.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! SMTP mailer (plain SMTP, no TLS).
|
||||
//!
|
||||
//! Configured via environment variables:
|
||||
//! SMTP_HOST — default: 127.0.0.1 (mailpit in dev)
|
||||
//! SMTP_PORT — default: 1025 (mailpit default)
|
||||
//! SMTP_FROM — default: noreply@trictrac.local
|
||||
//! SMTP_USER — optional SMTP credentials
|
||||
//! SMTP_PASSWORD — optional SMTP credentials
|
||||
//! APP_URL — default: http://localhost:9091 (frontend base URL for email links)
|
||||
//!
|
||||
//! For production with TLS, run a local relay (e.g. Postfix) or use an SMTP
|
||||
//! service that accepts plain connections on a local port and handles TLS externally.
|
||||
|
||||
use lettre::{
|
||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
message::Mailbox,
|
||||
transport::smtp::authentication::Credentials as SmtpCredentials,
|
||||
};
|
||||
|
||||
pub struct Mailer {
|
||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||
from: Mailbox,
|
||||
app_url: String,
|
||||
}
|
||||
|
||||
impl Mailer {
|
||||
pub fn from_env() -> Self {
|
||||
let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port: u16 = std::env::var("SMTP_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(1025);
|
||||
let from_str = std::env::var("SMTP_FROM")
|
||||
.unwrap_or_else(|_| "noreply@trictrac.local".to_string());
|
||||
let app_url = std::env::var("APP_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:9091".to_string());
|
||||
|
||||
let mut builder =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host).port(port);
|
||||
if let (Ok(user), Ok(pass)) = (std::env::var("SMTP_USER"), std::env::var("SMTP_PASSWORD")) {
|
||||
builder = builder.credentials(SmtpCredentials::new(user, pass));
|
||||
}
|
||||
let transport = builder.build();
|
||||
|
||||
let from = from_str
|
||||
.parse()
|
||||
.unwrap_or_else(|_| "noreply@trictrac.local".parse().unwrap());
|
||||
|
||||
Self { transport, from, app_url }
|
||||
}
|
||||
|
||||
pub async fn send_verification(&self, to_email: &str, token: &str) {
|
||||
let link = format!("{}/verify-email?token={}", self.app_url, token);
|
||||
let body = format!(
|
||||
"Welcome to Trictrac!\n\n\
|
||||
Please verify your email address by clicking the link below:\n\n\
|
||||
{link}\n\n\
|
||||
This link expires in 24 hours.\n"
|
||||
);
|
||||
self.send(to_email, "Verify your Trictrac account", body).await;
|
||||
}
|
||||
|
||||
pub async fn send_password_reset(&self, to_email: &str, token: &str) {
|
||||
let link = format!("{}/reset-password?token={}", self.app_url, token);
|
||||
let body = format!(
|
||||
"You requested a password reset for your Trictrac account.\n\n\
|
||||
Click the link below to choose a new password:\n\n\
|
||||
{link}\n\n\
|
||||
This link expires in 1 hour.\n\
|
||||
If you did not request this, you can safely ignore this email.\n"
|
||||
);
|
||||
self.send(to_email, "Reset your Trictrac password", body).await;
|
||||
}
|
||||
|
||||
async fn send(&self, to_email: &str, subject: &str, body: String) {
|
||||
let to: Mailbox = match to_email.parse() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("SMTP: invalid recipient address {to_email:?}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let email = match Message::builder()
|
||||
.from(self.from.clone())
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.body(body)
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("SMTP: failed to build message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.transport.send(email).await {
|
||||
tracing::warn!("SMTP: send failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue