124 lines
3.7 KiB
Python
124 lines
3.7 KiB
Python
"""SMTP-backed email sender.
|
|
|
|
Generic primitive used by registration/verification today, reusable for alert
|
|
digests and other notifications later. Configuration lives in the `smtp` row of
|
|
`system_settings` and is loaded once per send (small surface, no caching layer
|
|
yet to keep behavior obvious after settings changes).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from email.message import EmailMessage
|
|
from typing import Optional
|
|
|
|
import aiosmtplib
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.enums import OtpPurpose
|
|
|
|
|
|
class EmailError(Exception):
|
|
code: str = "EMAIL_ERROR"
|
|
|
|
|
|
class EmailNotConfiguredError(EmailError):
|
|
code = "EMAIL_PROVIDER_NOT_CONFIGURED"
|
|
|
|
|
|
class EmailSendError(EmailError):
|
|
code = "EMAIL_SEND_FAILED"
|
|
|
|
|
|
async def _load_smtp_config(db: AsyncSession) -> dict:
|
|
from app.api.v1.settings import get_setting_payload # local import avoids cycle
|
|
|
|
payload = await get_setting_payload(db, "smtp")
|
|
if not payload.get("host") or not payload.get("from_address"):
|
|
raise EmailNotConfiguredError("SMTP host/from_address not set")
|
|
return payload
|
|
|
|
|
|
async def send_email(
|
|
db: AsyncSession,
|
|
*,
|
|
to: str,
|
|
subject: str,
|
|
text_body: str,
|
|
html_body: Optional[str] = None,
|
|
config: Optional[dict] = None,
|
|
) -> None:
|
|
cfg = config or await _load_smtp_config(db)
|
|
|
|
message = EmailMessage()
|
|
from_name = (cfg.get("from_name") or "").strip()
|
|
from_address = cfg["from_address"]
|
|
message["From"] = f"{from_name} <{from_address}>" if from_name else from_address
|
|
message["To"] = to
|
|
message["Subject"] = subject
|
|
message.set_content(text_body)
|
|
if html_body:
|
|
message.add_alternative(html_body, subtype="html")
|
|
|
|
use_tls = bool(cfg.get("use_tls", True))
|
|
use_starttls = bool(cfg.get("use_starttls", False))
|
|
port = int(cfg.get("port") or (465 if use_tls else 587))
|
|
|
|
try:
|
|
await aiosmtplib.send(
|
|
message,
|
|
hostname=cfg["host"],
|
|
port=port,
|
|
username=cfg.get("username") or None,
|
|
password=cfg.get("password") or None,
|
|
use_tls=use_tls and not use_starttls,
|
|
start_tls=use_starttls,
|
|
timeout=int(cfg.get("timeout_seconds") or 20),
|
|
)
|
|
except aiosmtplib.SMTPException as exc:
|
|
raise EmailSendError(str(exc)) from exc
|
|
except OSError as exc:
|
|
raise EmailSendError(str(exc)) from exc
|
|
|
|
|
|
_SUBJECTS: dict[OtpPurpose, str] = {
|
|
OtpPurpose.REGISTER: "Confirm your Planet account",
|
|
OtpPurpose.VERIFY_EMAIL: "Verify your Planet email",
|
|
OtpPurpose.RESET_PASSWORD: "Reset your Planet password",
|
|
}
|
|
|
|
_HEADLINES: dict[OtpPurpose, str] = {
|
|
OtpPurpose.REGISTER: "Welcome to Planet — confirm your email to activate your account.",
|
|
OtpPurpose.VERIFY_EMAIL: "Confirm your new email address to keep your Planet account active.",
|
|
OtpPurpose.RESET_PASSWORD: "Use this code to set a new password for your Planet account.",
|
|
}
|
|
|
|
|
|
async def send_verification_email(
|
|
db: AsyncSession,
|
|
*,
|
|
to: str,
|
|
code: str,
|
|
purpose: OtpPurpose,
|
|
config: Optional[dict] = None,
|
|
) -> None:
|
|
subject = _SUBJECTS[purpose]
|
|
headline = _HEADLINES[purpose]
|
|
text_body = (
|
|
f"{headline}\n\n"
|
|
f"Your verification code: {code}\n"
|
|
"This code expires in 10 minutes. If you did not request it, ignore this email.\n"
|
|
)
|
|
html_body = (
|
|
f"<p>{headline}</p>"
|
|
f"<p style=\"font-size:24px;letter-spacing:4px;font-family:monospace\"><b>{code}</b></p>"
|
|
"<p>This code expires in 10 minutes. If you did not request it, ignore this email.</p>"
|
|
)
|
|
await send_email(
|
|
db,
|
|
to=to,
|
|
subject=subject,
|
|
text_body=text_body,
|
|
html_body=html_body,
|
|
config=config,
|
|
)
|