diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index bd39a1f..41715ff 100644 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -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") diff --git a/backend/app/models/entities.py b/backend/app/models/entities.py index 3b9db54..7d06a82 100644 --- a/backend/app/models/entities.py +++ b/backend/app/models/entities.py @@ -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") diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py index 707b3d1..6c47902 100644 --- a/backend/app/schemas/common.py +++ b/backend/app/schemas/common.py @@ -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) diff --git a/backend/app/services/mail.py b/backend/app/services/mail.py index 4539a7b..ac05497 100644 --- a/backend/app/services/mail.py +++ b/backend/app/services/mail.py @@ -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: diff --git a/frontend/src/i18n/dictionaries.ts b/frontend/src/i18n/dictionaries.ts index 3350d4d..bc444b3 100644 --- a/frontend/src/i18n/dictionaries.ts +++ b/frontend/src/i18n/dictionaries.ts @@ -61,6 +61,10 @@ export const dictionaries: Record> = { smtpPort: 'SMTP Port', smtpUser: 'SMTP User', smtpPassword: 'SMTP Passwort', + smtpSecurity: 'SMTP Sicherheit', + smtpStarttls: 'STARTTLS (typisch Port 587)', + smtpTls: 'TLS/SSL (typisch Port 465)', + smtpNone: 'Keine Verschlüsselung', senderAddress: 'Absender-Adresse', senderName: 'Absender-Name', testMail: 'Testmail senden', @@ -128,6 +132,10 @@ export const dictionaries: Record> = { smtpPort: 'SMTP port', smtpUser: 'SMTP user', smtpPassword: 'SMTP password', + smtpSecurity: 'SMTP security', + smtpStarttls: 'STARTTLS (usually port 587)', + smtpTls: 'TLS/SSL (usually port 465)', + smtpNone: 'No encryption', senderAddress: 'Sender address', senderName: 'Sender name', testMail: 'Send test mail', diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 90c01e6..ddb4f6b 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -61,8 +61,15 @@ function AdminUsers() { function AdminMail() { const { t } = useI18n(); - const [form, setForm] = useState({ smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '', sender_address: '', sender_name: 'NexaPantry', use_tls: true, use_starttls: true }); + const [form, setForm] = useState({ smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '', sender_address: '', sender_name: 'NexaPantry', use_tls: false, use_starttls: true }); + const [testTo, setTestTo] = useState(''); const set = (key: string, value: string | number | boolean) => setForm((current) => ({ ...current, [key]: value })); + const security = form.use_starttls ? 'starttls' : form.use_tls ? 'tls' : 'none'; + const setSecurity = (value: string) => setForm((current) => ({ + ...current, + use_starttls: value === 'starttls', + use_tls: value === 'tls' + })); useEffect(() => { void api>('/admin/mail').then((data) => setForm((current) => ({ ...current, ...data, smtp_password: '' }))); }, []); return ( @@ -71,10 +78,19 @@ function AdminMail() { set('smtp_port', Number(e.target.value))} /> set('smtp_user', e.target.value)} /> set('smtp_password', e.target.value)} /> + setSecurity(e.target.value)}> + + + + set('sender_address', e.target.value)} /> set('sender_name', e.target.value)} /> +
{ event.preventDefault(); await api('/admin/mail/test', { method: 'POST', body: JSON.stringify({ to: testTo }) }); }}> + setTestTo(e.target.value)} /> + +
); }