Implement real SMTP delivery and transactional email auth flow
All checks were successful
CI / test (push) Successful in 21s

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.
This commit is contained in:
2026-03-07 22:24:22 +03:00
parent 683c8a49e2
commit ab65a8b768
6 changed files with 84 additions and 11 deletions

View File

@@ -40,7 +40,10 @@ SMTP_HOST=mailpit
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
EMAIL_PROVIDER=log
SMTP_USE_TLS=false SMTP_USE_TLS=false
SMTP_USE_SSL=false
SMTP_TIMEOUT_SECONDS=10
SMTP_FROM_EMAIL=no-reply@benyamessenger.local SMTP_FROM_EMAIL=no-reply@benyamessenger.local
MAILPIT_SMTP_PORT=1025 MAILPIT_SMTP_PORT=1025
MAILPIT_UI_PORT=8025 MAILPIT_UI_PORT=8025

View File

@@ -28,7 +28,10 @@ SMTP_HOST=localhost
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
EMAIL_PROVIDER=log
SMTP_USE_TLS=false SMTP_USE_TLS=false
SMTP_USE_SSL=false
SMTP_TIMEOUT_SECONDS=10
SMTP_FROM_EMAIL=no-reply@benyamessenger.local SMTP_FROM_EMAIL=no-reply@benyamessenger.local
LOGIN_RATE_LIMIT_PER_MINUTE=10 LOGIN_RATE_LIMIT_PER_MINUTE=10

View File

@@ -19,7 +19,7 @@ from app.auth.schemas import (
) )
from app.config.settings import settings from app.config.settings import settings
from app.database.session import get_db 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.models import User
from app.users.repository import create_user, get_user_by_email, get_user_by_id, get_user_by_username from app.users.repository import create_user, get_user_by_email, get_user_by_id, get_user_by_username
from app.utils.security import ( from app.utils.security import (
@@ -62,9 +62,12 @@ async def register_user(
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_token_expire_hours) 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.delete_email_verification_tokens_for_user(db, user.id)
await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at) await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at)
await db.commit() try:
await email_service.send_verification_email(payload.email, verification_token) 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()
async def verify_email(db: AsyncSession, payload: VerifyEmailRequest) -> None: async def verify_email(db: AsyncSession, payload: VerifyEmailRequest) -> None:
@@ -99,9 +102,12 @@ async def resend_verification_email(
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_token_expire_hours) 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.delete_email_verification_tokens_for_user(db, user.id)
await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at) await auth_repository.create_email_verification_token(db, user.id, verification_token, expires_at)
await db.commit() try:
await email_service.send_verification_email(user.email, verification_token) 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()
async def login_user(db: AsyncSession, payload: LoginRequest) -> TokenResponse: async def login_user(db: AsyncSession, payload: LoginRequest) -> TokenResponse:
@@ -168,9 +174,12 @@ async def request_password_reset(
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_token_expire_hours) 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.delete_password_reset_tokens_for_user(db, user.id)
await auth_repository.create_password_reset_token(db, user.id, reset_token, expires_at) await auth_repository.create_password_reset_token(db, user.id, reset_token, expires_at)
await db.commit() try:
await email_service.send_password_reset_email(user.email, reset_token) 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()
async def reset_password(db: AsyncSession, payload: ResetPasswordRequest) -> None: async def reset_password(db: AsyncSession, payload: ResetPasswordRequest) -> None:

View File

@@ -32,7 +32,10 @@ class Settings(BaseSettings):
smtp_port: int = 1025 smtp_port: int = 1025
smtp_username: str = "" smtp_username: str = ""
smtp_password: str = "" smtp_password: str = ""
email_provider: str = "log"
smtp_use_tls: bool = False smtp_use_tls: bool = False
smtp_use_ssl: bool = False
smtp_timeout_seconds: float = 10.0
smtp_from_email: str = "no-reply@benyamessenger.local" smtp_from_email: str = "no-reply@benyamessenger.local"
login_rate_limit_per_minute: int = 10 login_rate_limit_per_minute: int = 10

View File

@@ -1,22 +1,74 @@
import logging import logging
from email.message import EmailMessage
import aiosmtplib
from app.config.settings import settings from app.config.settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class EmailDeliveryError(Exception):
pass
class EmailService: class EmailService:
async def send_verification_email(self, email: str, token: str) -> None: async def send_verification_email(self, email: str, token: str) -> None:
verify_link = f"{settings.frontend_base_url}/verify-email?token={token}" verify_link = f"{settings.frontend_base_url}/verify-email?token={token}"
subject = "Verify your BenyaMessenger account" subject = "Verify your BenyaMessenger account"
body = f"Open this link to verify your account: {verify_link}" text = (
logger.info("EMAIL to=%s subject=%s body=%s", email, subject, body) "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 = (
"<p>Welcome to <b>BenyaMessenger</b>.</p>"
f"<p>Verify your email by opening this link:<br><a href='{verify_link}'>{verify_link}</a></p>"
"<p>If you did not create this account, ignore this email.</p>"
)
await self._send_email(email, subject, text, html)
async def send_password_reset_email(self, email: str, token: str) -> None: async def send_password_reset_email(self, email: str, token: str) -> None:
reset_link = f"{settings.frontend_base_url}/reset-password?token={token}" reset_link = f"{settings.frontend_base_url}/reset-password?token={token}"
subject = "Reset your BenyaMessenger password" subject = "Reset your BenyaMessenger password"
body = f"Open this link to reset your password: {reset_link}" text = (
logger.info("EMAIL to=%s subject=%s body=%s", email, subject, body) "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 = (
"<p>Password reset request.</p>"
f"<p>Open this link to reset your password:<br><a href='{reset_link}'>{reset_link}</a></p>"
"<p>If you did not request this, ignore this email.</p>"
)
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: def get_email_service() -> EmailService:

View File

@@ -24,7 +24,10 @@ x-app-env: &app-env
SMTP_PORT: ${SMTP_PORT:-1025} SMTP_PORT: ${SMTP_PORT:-1025}
SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-}
EMAIL_PROVIDER: ${EMAIL_PROVIDER:-log}
SMTP_USE_TLS: ${SMTP_USE_TLS:-false} 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} SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-no-reply@benyamessenger.local}
LOGIN_RATE_LIMIT_PER_MINUTE: ${LOGIN_RATE_LIMIT_PER_MINUTE:-10} LOGIN_RATE_LIMIT_PER_MINUTE: ${LOGIN_RATE_LIMIT_PER_MINUTE:-10}
REGISTER_RATE_LIMIT_PER_MINUTE: ${REGISTER_RATE_LIMIT_PER_MINUTE:-5} REGISTER_RATE_LIMIT_PER_MINUTE: ${REGISTER_RATE_LIMIT_PER_MINUTE:-5}