fix: improve SMTP configuration and error handling
- 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:
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -61,6 +61,10 @@ export const dictionaries: Record<Language, Record<string, string>> = {
|
||||
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<Language, Record<string, string>> = {
|
||||
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',
|
||||
|
||||
@@ -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<Record<string, string | number | boolean | null>>('/admin/mail').then((data) => setForm((current) => ({ ...current, ...data, smtp_password: '' }))); }, []);
|
||||
return (
|
||||
<Panel title={t('mail')}>
|
||||
@@ -71,10 +78,19 @@ function AdminMail() {
|
||||
<Field label={t('smtpPort')} type="number" value={form.smtp_port} onChange={(e) => set('smtp_port', Number(e.target.value))} />
|
||||
<Field label={t('smtpUser')} value={form.smtp_user} onChange={(e) => set('smtp_user', e.target.value)} />
|
||||
<Field label={t('smtpPassword')} type="password" value={form.smtp_password} onChange={(e) => set('smtp_password', e.target.value)} />
|
||||
<SelectField label={t('smtpSecurity')} value={security} onChange={(e) => setSecurity(e.target.value)}>
|
||||
<option value="starttls">{t('smtpStarttls')}</option>
|
||||
<option value="tls">{t('smtpTls')}</option>
|
||||
<option value="none">{t('smtpNone')}</option>
|
||||
</SelectField>
|
||||
<Field label={t('senderAddress')} type="email" value={form.sender_address} onChange={(e) => set('sender_address', e.target.value)} />
|
||||
<Field label={t('senderName')} value={form.sender_name} onChange={(e) => set('sender_name', e.target.value)} />
|
||||
<Button>{t('save')}</Button>
|
||||
</form>
|
||||
<form className="mt-4 flex flex-col gap-3 sm:flex-row" onSubmit={async (event) => { event.preventDefault(); await api('/admin/mail/test', { method: 'POST', body: JSON.stringify({ to: testTo }) }); }}>
|
||||
<Field className="sm:min-w-80" label={t('email')} type="email" required value={testTo} onChange={(e) => setTestTo(e.target.value)} />
|
||||
<Button className="self-end" variant="secondary">{t('testMail')}</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user