fix: improve SMTP configuration and error handling
Some checks failed
CI / backend (push) Failing after 17s
CI / frontend (push) Successful in 31s
CI / docker (push) Has been skipped

- Change default use_tls from True to False to match typical STARTTLS setup
- Add MailDeliveryError exception for mail delivery failures
- Wrap send_mail calls in try-catch blocks to handle errors gracefully
- Return 502 status code with error details when mail delivery fails
- Add SMTP security mode selector in frontend (STARTTLS/TLS/None)
- Add test mail form to admin panel
- Handle empty SMTP credentials properly in update_mail_settings
- Catch
This commit is contained in:
2026-06-04 11:00:11 +02:00
parent 15d47d49bf
commit 5ed613d441
6 changed files with 59 additions and 17 deletions

View File

@@ -17,6 +17,7 @@ from app.schemas.common import (
)
from app.services.audit import audit
from app.services.mail import (
MailDeliveryError,
get_mail_settings,
send_mail,
serialize_mail_settings,
@@ -49,7 +50,10 @@ def create_user(payload: UserCreate, admin: User = Depends(current_admin), db: S
db.add(user)
db.flush()
if payload.send_invite:
send_invitation(db, user)
try:
send_invitation(db, user)
except MailDeliveryError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
audit(db, admin, "admin.user.create", "user", user.id)
db.commit()
db.refresh(user)
@@ -86,7 +90,10 @@ def reset_password(user_id: str, admin: User = Depends(current_admin), db: Sessi
if not user:
raise HTTPException(status_code=404, detail="User not found")
token = create_reset_token(db, user)
send_mail(db, user.email, "NexaPantry password reset", f"Reset token: {token}")
try:
send_mail(db, user.email, "NexaPantry password reset", f"Reset token: {token}")
except MailDeliveryError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
audit(db, admin, "admin.user.reset_password", "user", user.id)
db.commit()
return Message(message="Password reset mail sent")
@@ -112,7 +119,10 @@ def save_mail_settings(payload: MailSettingsIn, admin: User = Depends(current_ad
@router.post("/mail/test", response_model=Message)
def test_mail(payload: TestMailIn, db: Session = Depends(get_db)) -> Message:
send_mail(db, str(payload.to), "NexaPantry test mail", "SMTP is configured correctly.")
try:
send_mail(db, str(payload.to), "NexaPantry test mail", "SMTP is configured correctly.")
except MailDeliveryError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return Message(message="Test mail sent")

View File

@@ -136,7 +136,7 @@ class MailSetting(Base):
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
smtp_user: Mapped[str | None] = mapped_column(String(220), nullable=True)
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
use_tls: Mapped[bool] = mapped_column(Boolean, default=False)
use_starttls: Mapped[bool] = mapped_column(Boolean, default=True)
sender_address: Mapped[str | None] = mapped_column(String(320), nullable=True)
sender_name: Mapped[str] = mapped_column(String(160), default="NexaPantry")

View File

@@ -139,7 +139,7 @@ class MailSettingsIn(BaseModel):
smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_user: str | None = Field(default=None, max_length=220)
smtp_password: str | None = Field(default=None, max_length=1000)
use_tls: bool = True
use_tls: bool = False
use_starttls: bool = True
sender_address: EmailStr | None = None
sender_name: str = Field(default="NexaPantry", max_length=160)

View File

@@ -1,4 +1,5 @@
import smtplib
import socket
from email.message import EmailMessage
from sqlalchemy.orm import Session
@@ -9,6 +10,10 @@ from app.models.entities import MailSetting
from app.schemas.common import MailSettingsIn, MailSettingsOut
class MailDeliveryError(RuntimeError):
pass
def get_mail_settings(db: Session) -> MailSetting:
settings = db.get(MailSetting, 1)
if not settings:
@@ -33,12 +38,12 @@ def serialize_mail_settings(settings: MailSetting) -> MailSettingsOut:
def update_mail_settings(db: Session, payload: MailSettingsIn) -> MailSetting:
settings = get_mail_settings(db)
settings.smtp_host = payload.smtp_host
settings.smtp_host = payload.smtp_host or None
settings.smtp_port = payload.smtp_port
settings.smtp_user = payload.smtp_user
if payload.smtp_password is not None:
settings.smtp_user = payload.smtp_user or None
if payload.smtp_password:
settings.smtp_password_encrypted = encrypt_secret(payload.smtp_password)
settings.use_tls = payload.use_tls
settings.use_tls = payload.use_tls and not payload.use_starttls
settings.use_starttls = payload.use_starttls
settings.sender_address = str(payload.sender_address) if payload.sender_address else None
settings.sender_name = payload.sender_name
@@ -48,7 +53,7 @@ def update_mail_settings(db: Session, payload: MailSettingsIn) -> MailSetting:
def send_mail(db: Session, to: str, subject: str, body: str) -> None:
settings = get_mail_settings(db)
if not settings.smtp_host or not settings.sender_address:
raise RuntimeError("SMTP is not configured")
raise MailDeliveryError("SMTP is not configured")
message = EmailMessage()
message["From"] = f"{settings.sender_name} <{settings.sender_address}>"
message["To"] = to
@@ -56,12 +61,15 @@ def send_mail(db: Session, to: str, subject: str, body: str) -> None:
message.set_content(body)
password = decrypt_secret(settings.smtp_password_encrypted)
client_cls = smtplib.SMTP_SSL if settings.use_tls and not settings.use_starttls else smtplib.SMTP
with client_cls(settings.smtp_host, settings.smtp_port, timeout=20) as smtp:
if settings.use_starttls:
smtp.starttls()
if settings.smtp_user and password:
smtp.login(settings.smtp_user, password)
smtp.send_message(message)
try:
with client_cls(settings.smtp_host, settings.smtp_port, timeout=20) as smtp:
if settings.use_starttls:
smtp.starttls()
if settings.smtp_user and password:
smtp.login(settings.smtp_user, password)
smtp.send_message(message)
except (OSError, smtplib.SMTPException, socket.timeout) as exc:
raise MailDeliveryError(f"SMTP delivery failed: {exc}") from exc
def invite_body(token: str) -> str: