feat(config): DB-backed system_config infrastructure + SMTP group
Add a generic admin-editable config layer (epic 4a): system_config KV table (migration 0021), a sysconfig registry that is the single source of truth for DB-overridable keys (type/group/default/bounds/secret) + a DB-override-or-env resolver, and admin endpoints (GET/PATCH/DELETE /api/admin/config + a test-email probe). Secrets are Fernet-encrypted at rest (TOKEN_ENCRYPTION_KEY); without it they stay env-only. First group wired end-to-end: Email/SMTP — email.py now reads host/port/user/from/password via the resolver (own session, since email is sent from background tasks).
This commit is contained in:
parent
24d626d05b
commit
48cb6a5dbd
6 changed files with 314 additions and 5 deletions
|
|
@ -10,13 +10,29 @@ import ssl
|
|||
from email.message import EmailMessage
|
||||
from email.utils import formatdate
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
|
||||
log = logging.getLogger("subfeed.email")
|
||||
|
||||
|
||||
def _smtp() -> dict:
|
||||
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
|
||||
session because email is often sent from a BackgroundTask, outside a request's db."""
|
||||
with SessionLocal() as db:
|
||||
return {
|
||||
"host": sysconfig.get_str(db, "smtp_host"),
|
||||
"port": sysconfig.get_int(db, "smtp_port"),
|
||||
"user": sysconfig.get_str(db, "smtp_user"),
|
||||
"password": sysconfig.get_str(db, "smtp_password"),
|
||||
"from": sysconfig.get_str(db, "smtp_from"),
|
||||
}
|
||||
|
||||
|
||||
def email_enabled() -> bool:
|
||||
return bool(settings.smtp_host and settings.smtp_user and settings.smtp_password)
|
||||
c = _smtp()
|
||||
return bool(c["host"] and c["user"] and c["password"])
|
||||
|
||||
|
||||
def _admin_contact() -> str | None:
|
||||
|
|
@ -27,12 +43,13 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
|||
recipients = [e for e in to if e]
|
||||
if not recipients:
|
||||
return False
|
||||
if not email_enabled():
|
||||
c = _smtp()
|
||||
if not (c["host"] and c["user"] and c["password"]):
|
||||
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
|
||||
return False
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from or settings.smtp_user
|
||||
msg["From"] = c["from"] or c["user"]
|
||||
msg["To"] = ", ".join(recipients)
|
||||
# A real Date and a Reply-To (so it's a conversation, not a no-reply blast) both
|
||||
# nudge spam filters the right way; reputation still does most of the work.
|
||||
|
|
@ -41,9 +58,9 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
|||
msg["Reply-To"] = reply_to
|
||||
msg.set_content(body)
|
||||
try:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as s:
|
||||
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
|
||||
s.starttls(context=ssl.create_default_context())
|
||||
s.login(settings.smtp_user, settings.smtp_password)
|
||||
s.login(c["user"], c["password"])
|
||||
s.send_message(msg)
|
||||
log.info("Sent email %r to %s", subject, recipients)
|
||||
return True
|
||||
|
|
@ -73,3 +90,12 @@ def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
|||
"(Reply to this email to reach the requester directly.)\n"
|
||||
)
|
||||
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
||||
|
||||
|
||||
def send_test(to: str) -> bool:
|
||||
body = (
|
||||
"This is a test email from Siftlode.\n\n"
|
||||
"If you're reading this, your SMTP settings work.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Siftlode SMTP test", body)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue