feat: add scheduler and mail outbox
SMTP adapter with TLS and a fake, outbox with retries, backoff, rate limit and restart safety, scheduler tick with job-run records started by gunicorn, and a send_test_mail command. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
@@ -5,10 +5,13 @@ from __future__ import annotations
|
||||
import ipaddress
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from email.utils import parseaddr
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
SMTP_SECURITY_MODES = ("starttls", "ssl")
|
||||
SMTP_DEFAULT_PORTS = {"starttls": 587, "ssl": 465}
|
||||
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||
MIN_SECRET_KEY_LENGTH = 32
|
||||
_TRUE = {"1", "true", "yes", "on"}
|
||||
@@ -55,6 +58,16 @@ class OidcConfig:
|
||||
return f"{self.public_base_url}/auth/callback"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmtpConfig:
|
||||
host: str
|
||||
port: int
|
||||
security: str # "starttls" or "ssl"; mail is never sent unencrypted
|
||||
username: str
|
||||
password: str = field(repr=False)
|
||||
sender: str # the From address of every e-mail
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
secret_key: str
|
||||
@@ -66,6 +79,7 @@ class Config:
|
||||
default_timezone: str
|
||||
directory: DirectoryConfig
|
||||
oidc: OidcConfig
|
||||
smtp: SmtpConfig
|
||||
|
||||
|
||||
def load_config(environ: Mapping[str, str]) -> Config:
|
||||
@@ -162,6 +176,32 @@ def load_config(environ: Mapping[str, str]) -> Config:
|
||||
if parts.scheme not in allowed or not parts.netloc or parts.path or parts.query:
|
||||
problems.append("PUBLIC_BASE_URL: must be an https origin such as https://host")
|
||||
|
||||
smtp_host = required("SMTP_HOST", "host name of the mail server")
|
||||
if "://" in smtp_host or "/" in smtp_host or ":" in smtp_host:
|
||||
problems.append("SMTP_HOST: must be a host name only; set the port in SMTP_PORT")
|
||||
smtp_security = (raw("SMTP_SECURITY") or "starttls").lower()
|
||||
if smtp_security not in SMTP_SECURITY_MODES:
|
||||
problems.append(f"SMTP_SECURITY: must be one of {', '.join(SMTP_SECURITY_MODES)}")
|
||||
smtp_security = "starttls"
|
||||
smtp_port = SMTP_DEFAULT_PORTS[smtp_security]
|
||||
port_raw = raw("SMTP_PORT")
|
||||
if port_raw is not None:
|
||||
if port_raw.isdigit() and 0 < int(port_raw) < 65536:
|
||||
smtp_port = int(port_raw)
|
||||
else:
|
||||
problems.append("SMTP_PORT: must be a port number from 1 to 65535")
|
||||
smtp_username = required("SMTP_USERNAME", "user name of the mailbox")
|
||||
smtp_password = required("SMTP_PASSWORD", "password of the mailbox")
|
||||
sender = required("MAIL_FROM", "sender address of the portal's e-mails")
|
||||
address = parseaddr(sender)[1]
|
||||
if sender and (
|
||||
address.count("@") != 1
|
||||
or any(c in sender for c in chr(13) + chr(10))
|
||||
or address.startswith("@")
|
||||
or address.endswith("@")
|
||||
):
|
||||
problems.append("MAIL_FROM: must be an e-mail address, optionally with a display name")
|
||||
|
||||
if problems:
|
||||
raise ConfigError(problems)
|
||||
|
||||
@@ -188,4 +228,12 @@ def load_config(environ: Mapping[str, str]) -> Config:
|
||||
client_secret=client_secret,
|
||||
public_base_url=base_url,
|
||||
),
|
||||
smtp=SmtpConfig(
|
||||
host=smtp_host,
|
||||
port=smtp_port,
|
||||
security=smtp_security,
|
||||
username=smtp_username,
|
||||
password=smtp_password,
|
||||
sender=sender,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user