"""Outbound email for onboarding (access requests + approval notices). Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't configured or a send errors, we log and return False so the caller can fall back to in-app notifications — email is never load-bearing for the app's core flow. Fail-soft must not mean invisible, though: every drop also leaves an in-app notice in the admins' inbox (see `_alert_admins`). An instance whose SMTP was never filled in silently swallows verification links, password resets and access-request notices, and the operator's first clue used to be a person telling them nothing arrived. """ import html import logging import re import smtplib import ssl from email.message import EmailMessage from email.utils import formatdate from sqlalchemy import select from sqlalchemy.orm import Session from app import mailtext, sysconfig from app.config import settings from app.db import SessionLocal from app.models import User from app.notifications import create_notification from app.ratelimit import RateLimiter log = logging.getLogger("siftlode.email") # One admin notice an hour per reason. A relay that is down fails every send, and the inbox must # stay readable; the log keeps the full record either way. _alert_throttle = RateLimiter(max_events=1, window_seconds=3600) def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None: """Record an in-app notice that a message did NOT go out. Best effort in every direction: it opens its own session (callers are often background tasks), swallows its own errors, and never reports failure to the caller — this is the fallback path, it must not become a new way to break a send.""" if not _alert_throttle.allow(reason): return try: with SessionLocal() as db: admins = ( db.execute( select(User.id).where( User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False), ) ) .scalars() .all() ) if not admins: # Nothing was recorded, so don't let this cost the next hour of alerts on the very # path whose job is to stop drops going unseen. Same on the failure branch below. _alert_throttle.reset() return for uid in admins: create_notification( db, user_id=uid, type="email_undelivered", # English fallback; the inbox renders a translated version from `data`. title="Email could not be sent", body=subject, data={"reason": reason, "subject": subject, "recipients": recipients}, commit=False, ) db.commit() except Exception: _alert_throttle.reset() log.exception("Could not record the undelivered-email notice (%r)", subject) def _read_smtp(db: Session) -> dict: 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 _smtp(db: Session | None = None) -> dict: """Effective SMTP config (admin DB override over env defaults). Opens its own short session when the caller has none, because email is usually sent from a BackgroundTask outside a request's db. A caller that IS inside a request should pass its session: reading the password decrypts a Fernet value, and /api/me polls this for every admin tab.""" if db is not None: return _read_smtp(db) with SessionLocal() as own: return _read_smtp(own) def email_enabled(db: Session | None = None) -> bool: c = _smtp(db) return bool(c["host"] and c["user"] and c["password"]) def _admin_contact() -> str | None: return next(iter(sorted(settings.admin_email_set)), None) # A bare URL in a text/plain mail is only clickable if the CLIENT decides to linkify it. Gmail, # Outlook and Apple Mail do; plenty of lighter web clients render the body verbatim and leave the # link as dead text — which, for a verification or password-reset mail, is the whole message. So # every mail also carries an HTML alternative built from the SAME plain body (below), rather than # per-template HTML that would have to be kept in sync with it. # # `[^\s<]` stops at whitespace and at the `<` of any following markup; the trailing-punctuation trim # keeps a sentence-final period out of the href. `;` and `:` are deliberately NOT trimmed — escaping # turns `&` into `&`, so a query string can legitimately end in a semicolon. _URL_RE = re.compile(r"https?://[^\s<]+") def _html_body(text: str) -> str: """Render a plain-text mail body as minimal HTML: escaped, blank-line-separated paragraphs, with real links. Escaping happens FIRST and the linkifier then runs over the escaped text — bodies interpolate untrusted values (a requester's email address, the operator contact), so no caller can inject markup, and `&` inside a URL is already `&` by the time it reaches the href, which is what an HTML attribute wants anyway.""" def _anchor(m: re.Match[str]) -> str: url = m.group(0) trail = "" while url and url[-1] in ".,!?)": url, trail = url[:-1], url[-1] + trail if not url: return trail return f'{url}{trail}' paragraphs = [p for p in html.escape(text).split("\n\n") if p.strip()] rendered = "".join( f"
{_URL_RE.sub(_anchor, p).replace(chr(10), '
')}