From ab65a8b768f4bbb4e14a441d48a73d0274d96375 Mon Sep 17 00:00:00 2001 From: benya Date: Sat, 7 Mar 2026 22:24:22 +0300 Subject: [PATCH] Implement real SMTP delivery and transactional email auth flow Email delivery: - Replaced logging-only email sender with aiosmtplib SMTP implementation. - Added provider mode switch via EMAIL_PROVIDER (log/smtp). - Added TLS/SSL and timeout controls for SMTP transport. Auth registration flow: - Made register/resend/reset email flows transactional with rollback on delivery failure. - Return 503 when verification/reset email cannot be delivered. Configuration: - Extended settings and env templates for EMAIL_PROVIDER, SMTP_USE_SSL, SMTP_TIMEOUT_SECONDS. - Updated docker-compose environment mapping for new SMTP variables. --- .env.docker.example | 3 +++ .env.example | 3 +++ app/auth/service.py | 23 +++++++++++----- app/config/settings.py | 3 +++ app/email/service.py | 60 +++++++++++++++++++++++++++++++++++++++--- docker-compose.yml | 3 +++ 6 files changed, 84 insertions(+), 11 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index 7cfa9a0..7b46c2f 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -40,7 +40,10 @@ SMTP_HOST=mailpit SMTP_PORT=1025 SMTP_USERNAME= SMTP_PASSWORD= +EMAIL_PROVIDER=log SMTP_USE_TLS=false +SMTP_USE_SSL=false +SMTP_TIMEOUT_SECONDS=10 SMTP_FROM_EMAIL=no-reply@benyamessenger.local MAILPIT_SMTP_PORT=1025 MAILPIT_UI_PORT=8025 diff --git a/.env.example b/.env.example index 4c41b4d..19c5aac 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,10 @@ SMTP_HOST=localhost SMTP_PORT=1025 SMTP_USERNAME= SMTP_PASSWORD= +EMAIL_PROVIDER=log SMTP_USE_TLS=false +SMTP_USE_SSL=false +SMTP_TIMEOUT_SECONDS=10 SMTP_FROM_EMAIL=no-reply@benyamessenger.local LOGIN_RATE_LIMIT_PER_MINUTE=10 diff --git a/app/auth/service.py b/app/auth/service.py index 3085183..b4e9114 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -19,7 +19,7 @@ from app.auth.schemas import ( ) from app.config.settings import settings from app.database.session import get_db -from app.email.service import EmailService, get_email_service +from app.email.service import EmailDeliveryError, EmailService, get_email_service from app.users.models import User from app.users.repository import create_user, get_user_by_email, get_user_by_id, get_user_by_username from app.utils.security import ( @@ -62,10 +62,13 @@ async def register_user( expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_token_expire_hours) await auth_repository.delete_email_verification_tokens_for_user(db, user.id) await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at) + try: + await email_service.send_verification_email(payload.email, verification_token) + except EmailDeliveryError as exc: + await db.rollback() + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unable to send verification email") from exc await db.commit() - await email_service.send_verification_email(payload.email, verification_token) - async def verify_email(db: AsyncSession, payload: VerifyEmailRequest) -> None: record = await auth_repository.get_email_verification_token(db, payload.token) @@ -99,10 +102,13 @@ async def resend_verification_email( expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_token_expire_hours) await auth_repository.delete_email_verification_tokens_for_user(db, user.id) await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at) + try: + await email_service.send_verification_email(user.email, verification_token) + except EmailDeliveryError as exc: + await db.rollback() + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unable to send verification email") from exc await db.commit() - await email_service.send_verification_email(user.email, verification_token) - async def login_user(db: AsyncSession, payload: LoginRequest) -> TokenResponse: user = await get_user_by_email(db, payload.email) @@ -168,10 +174,13 @@ async def request_password_reset( expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_token_expire_hours) await auth_repository.delete_password_reset_tokens_for_user(db, user.id) await auth_repository.create_password_reset_token(db, user.id, reset_token, expires_at) + try: + await email_service.send_password_reset_email(user.email, reset_token) + except EmailDeliveryError as exc: + await db.rollback() + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unable to send reset email") from exc await db.commit() - await email_service.send_password_reset_email(user.email, reset_token) - async def reset_password(db: AsyncSession, payload: ResetPasswordRequest) -> None: record = await auth_repository.get_password_reset_token(db, payload.token) diff --git a/app/config/settings.py b/app/config/settings.py index 85855ef..f72dac9 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -32,7 +32,10 @@ class Settings(BaseSettings): smtp_port: int = 1025 smtp_username: str = "" smtp_password: str = "" + email_provider: str = "log" smtp_use_tls: bool = False + smtp_use_ssl: bool = False + smtp_timeout_seconds: float = 10.0 smtp_from_email: str = "no-reply@benyamessenger.local" login_rate_limit_per_minute: int = 10 diff --git a/app/email/service.py b/app/email/service.py index 9e7b7fa..ceb52c0 100644 --- a/app/email/service.py +++ b/app/email/service.py @@ -1,22 +1,74 @@ import logging +from email.message import EmailMessage + +import aiosmtplib from app.config.settings import settings logger = logging.getLogger(__name__) +class EmailDeliveryError(Exception): + pass + + class EmailService: async def send_verification_email(self, email: str, token: str) -> None: verify_link = f"{settings.frontend_base_url}/verify-email?token={token}" subject = "Verify your BenyaMessenger account" - body = f"Open this link to verify your account: {verify_link}" - logger.info("EMAIL to=%s subject=%s body=%s", email, subject, body) + text = ( + "Welcome to BenyaMessenger.\n\n" + f"Verify your email by opening this link:\n{verify_link}\n\n" + "If you did not create this account, ignore this email." + ) + html = ( + "

Welcome to BenyaMessenger.

" + f"

Verify your email by opening this link:
{verify_link}

" + "

If you did not create this account, ignore this email.

" + ) + await self._send_email(email, subject, text, html) async def send_password_reset_email(self, email: str, token: str) -> None: reset_link = f"{settings.frontend_base_url}/reset-password?token={token}" subject = "Reset your BenyaMessenger password" - body = f"Open this link to reset your password: {reset_link}" - logger.info("EMAIL to=%s subject=%s body=%s", email, subject, body) + text = ( + "Password reset request.\n\n" + f"Open this link to reset your password:\n{reset_link}\n\n" + "If you did not request this, ignore this email." + ) + html = ( + "

Password reset request.

" + f"

Open this link to reset your password:
{reset_link}

" + "

If you did not request this, ignore this email.

" + ) + await self._send_email(email, subject, text, html) + + async def _send_email(self, recipient: str, subject: str, text: str, html: str) -> None: + if settings.email_provider.lower() != "smtp": + logger.info("EMAIL to=%s subject=%s body=%s", recipient, subject, text) + return + + message = EmailMessage() + message["From"] = settings.smtp_from_email + message["To"] = recipient + message["Subject"] = subject + message.set_content(text) + message.add_alternative(html, subtype="html") + + try: + await aiosmtplib.send( + message, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username or None, + password=settings.smtp_password or None, + start_tls=settings.smtp_use_tls, + use_tls=settings.smtp_use_ssl, + timeout=settings.smtp_timeout_seconds, + ) + except Exception as exc: + logger.exception("SMTP delivery failed to=%s subject=%s", recipient, subject) + raise EmailDeliveryError("Email delivery failed") from exc def get_email_service() -> EmailService: diff --git a/docker-compose.yml b/docker-compose.yml index 4f8559d..49d0dcc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,10 @@ x-app-env: &app-env SMTP_PORT: ${SMTP_PORT:-1025} SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} + EMAIL_PROVIDER: ${EMAIL_PROVIDER:-log} SMTP_USE_TLS: ${SMTP_USE_TLS:-false} + SMTP_USE_SSL: ${SMTP_USE_SSL:-false} + SMTP_TIMEOUT_SECONDS: ${SMTP_TIMEOUT_SECONDS:-10} SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-no-reply@benyamessenger.local} LOGIN_RATE_LIMIT_PER_MINUTE: ${LOGIN_RATE_LIMIT_PER_MINUTE:-10} REGISTER_RATE_LIMIT_PER_MINUTE: ${REGISTER_RATE_LIMIT_PER_MINUTE:-5}