25 literal names covered 34 modules. plex/client.py, plex/stream.py, plex/subtitles.py, plex/sync.py and two Plex route modules all called themselves `siftlode.plex`; downloads/pipeline.py called itself `siftlode.worker`; routes/channels.py called itself `siftlode.api`. The `logger` column is in every stored row so E22.6's console can filter on it, and so an operator can pin a level to one part of the system — neither works when six modules share a name. `logs.get_logger(__name__)` derives it: app/plex/sync.py -> siftlode.plex.sync. One name per module, and it cannot drift from the file it sits in. tests/test_logger_names.py pins the derivation, that everything stays under the one `siftlode` tree the handler and the tests attach to, and that no module hand-rolls a name again unless the line says why (`# logger-name-ok: <why>`). Two do: get_logger's own call, and `siftlode.http`, which is named for the traffic it carries rather than the module it lives in — it replaced uvicorn's access log and is what an operator pins when they want request lines and nothing else. app/scheduler.py called its logger `logger`; every other module calls it `log`. ruff gains LOG, G, T20, TRY400, TRY401 and S112. Four findings cleared with them: worker.py logged `str(exc)[:120]` where the traceback was the whole question, and three prints/f-strings that are deliberate now say so on the line. S110 and BLE001 stay off until S3 — 23 handlers are outstanding and each is a judgement about one of them; enabling the rules first would just mean 23 blanket noqas.
341 lines
15 KiB
Python
341 lines
15 KiB
Python
"""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 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 logs, 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 = logs.get_logger(__name__)
|
|
# 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'<a href="{url}">{url}</a>{trail}'
|
|
|
|
paragraphs = [p for p in html.escape(text).split("\n\n") if p.strip()]
|
|
rendered = "".join(
|
|
f"<p>{_URL_RE.sub(_anchor, p).replace(chr(10), '<br>')}</p>" for p in paragraphs
|
|
)
|
|
# Inline styles only — many clients drop <style> blocks. No images, no external CSS, no tracking:
|
|
# the HTML part exists to make the link clickable, not to become a template to maintain.
|
|
return (
|
|
'<html><body style="margin:0;padding:16px;font-family:system-ui,-apple-system,'
|
|
"'Segoe UI',sans-serif;font-size:15px;line-height:1.6\">"
|
|
f'<div style="max-width:560px">{rendered}</div>'
|
|
"</body></html>"
|
|
)
|
|
|
|
|
|
def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -> bool:
|
|
recipients = [e for e in to if e]
|
|
if not recipients:
|
|
return False
|
|
c = _smtp()
|
|
if not (c["host"] and c["user"] and c["password"]):
|
|
# The count, not the addresses: `redact_text` rewrites every one of them to `[email]`
|
|
# before either sink sees the line, so the list only ever printed as noise. The subject
|
|
# stays — the templates are static, and the one that interpolates (`admin_new_request`)
|
|
# interpolates an address, which the same redactor covers.
|
|
log.info(
|
|
"SMTP not configured; skipping email %r to %d recipient(s)",
|
|
subject,
|
|
len(recipients), # log-pii-ok: how many, not who
|
|
)
|
|
_alert_admins("not_configured", subject, recipients)
|
|
return False
|
|
msg = EmailMessage()
|
|
msg["Subject"] = subject
|
|
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.
|
|
msg["Date"] = formatdate(localtime=True)
|
|
if reply_to:
|
|
msg["Reply-To"] = reply_to
|
|
# text/plain stays the primary part (it is the source of truth, and a text-only client still
|
|
# gets the full message); the HTML alternative below is what makes the link clickable everywhere.
|
|
msg.set_content(body)
|
|
msg.add_alternative(_html_body(body), subtype="html")
|
|
try:
|
|
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
|
|
s.starttls(context=ssl.create_default_context())
|
|
s.login(c["user"], c["password"])
|
|
s.send_message(msg)
|
|
# log-pii-ok: how many, not who
|
|
log.info("Sent email %r to %d recipient(s)", subject, len(recipients))
|
|
return True
|
|
except Exception:
|
|
# log-pii-ok: how many, not who
|
|
log.exception("SMTP send failed (%r to %d recipient(s))", subject, len(recipients))
|
|
_alert_admins("send_failed", subject, recipients)
|
|
return False
|
|
|
|
|
|
def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str) -> str:
|
|
"""What this person actually has to do next, in the one paragraph that carries the whole flow.
|
|
|
|
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
|
|
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
|
|
refuse them (E9.5 S4 named that state on screen; the mail must not contradict it).
|
|
|
|
The link branch is the point of the C option: in the common ordering — approve first, the person
|
|
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
|
|
messages. One mail, one click.
|
|
"""
|
|
if verify_link:
|
|
return mailtext.text(
|
|
"next_step.verify", lang, link=verify_link, expiry=mailtext.link_expiry(hours, lang)
|
|
)
|
|
if has_account:
|
|
return mailtext.text("next_step.sign_in", lang, app=settings.app_base)
|
|
return mailtext.text("next_step.register", lang, app=settings.app_base)
|
|
|
|
|
|
def _reply_contact(lang: str) -> tuple[str | None, str]:
|
|
"""The admin address to answer to, and the closing line that names it — empty when no admin
|
|
contact is configured, so the mail never promises a reply nobody will read."""
|
|
admin = _admin_contact()
|
|
return admin, (mailtext.text("contact.reply", lang, admin=admin) if admin else "")
|
|
|
|
|
|
def send_access_approved(
|
|
email: str,
|
|
lang: str,
|
|
verify_link: str | None = None,
|
|
has_account: bool = False,
|
|
hours: int = 24,
|
|
) -> bool:
|
|
"""Answer someone who ASKED for access. Sent automatically on approval, because a person who
|
|
made a request is owed a reply — unlike the admin-initiated invitation below."""
|
|
admin, contact = _reply_contact(lang)
|
|
body = mailtext.text(
|
|
"access_approved.body",
|
|
lang,
|
|
next_step=_next_step(verify_link, has_account, hours, lang),
|
|
contact=contact,
|
|
)
|
|
return _send([email], mailtext.text("access_approved.subject", lang), body, reply_to=admin)
|
|
|
|
|
|
def send_access_invitation(
|
|
email: str,
|
|
lang: str,
|
|
verify_link: str | None = None,
|
|
has_account: bool = False,
|
|
hours: int = 24,
|
|
) -> bool:
|
|
"""Invite someone the admin let in WITHOUT them asking — sent only when the admin presses send.
|
|
|
|
Whitelisting an address is opening a door, not knocking on one: the operator has usually already
|
|
agreed it with the person, and an address can sit approved for weeks without anyone registering.
|
|
So adding to the whitelist stays silent and this is a separate, deliberate act. That is also why
|
|
the closing line offers no deadline — nudging is the one thing this mail must not do.
|
|
|
|
Its language is the one place the app has no signal to read: the admin typed this address in, so
|
|
nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7).
|
|
"""
|
|
admin, contact = _reply_contact(lang)
|
|
body = mailtext.text(
|
|
"access_invitation.body",
|
|
lang,
|
|
next_step=_next_step(verify_link, has_account, hours, lang),
|
|
contact=contact,
|
|
)
|
|
return _send([email], mailtext.text("access_invitation.subject", lang), body, reply_to=admin)
|
|
|
|
|
|
def send_access_denied(email: str, lang: str) -> bool:
|
|
admin, contact = _reply_contact(lang)
|
|
body = mailtext.text("access_denied.body", lang, contact=contact)
|
|
return _send([email], mailtext.text("access_denied.subject", lang), body, reply_to=admin)
|
|
|
|
|
|
def send_role_changed(to: str, lang: str, role: str, operator: str | None) -> bool:
|
|
line = mailtext.text(
|
|
"role_changed.granted" if role == "admin" else "role_changed.revoked", lang
|
|
)
|
|
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
|
body = mailtext.text("role_changed.body", lang, line=line, contact=contact)
|
|
return _send([to], mailtext.text("role_changed.subject", lang), body, reply_to=operator)
|
|
|
|
|
|
def send_account_unsuspended(to: str, lang: str, operator: str | None) -> bool:
|
|
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
|
body = mailtext.text("account_unsuspended.body", lang, contact=contact)
|
|
return _send([to], mailtext.text("account_unsuspended.subject", lang), body, reply_to=operator)
|
|
|
|
|
|
def send_account_deleted(to: str, lang: str, operator: str | None) -> bool:
|
|
contact = mailtext.text("contact.deleted", lang, operator=operator) if operator else ""
|
|
body = mailtext.text("account_deleted.body", lang, contact=contact)
|
|
return _send([to], mailtext.text("account_deleted.subject", lang), body, reply_to=operator)
|
|
|
|
|
|
def send_account_suspended(to: str, lang: str, operator: str | None) -> bool:
|
|
contact = (
|
|
mailtext.text("contact.suspended", lang, operator=operator)
|
|
if operator
|
|
else mailtext.text("contact.suspended_unknown", lang)
|
|
)
|
|
body = mailtext.text("account_suspended.body", lang, contact=contact)
|
|
return _send([to], mailtext.text("account_suspended.subject", lang), body, reply_to=operator)
|
|
|
|
|
|
def send_admin_new_request(admins: list[str], lang: str, requester: str) -> bool:
|
|
"""Tell the admins someone is waiting. One call per language group (see
|
|
`routes.auth.notify_admins_of_request`) — admins are people with their own UI language, and a
|
|
single blast to all of them would have to pick one and be wrong for the rest.
|
|
|
|
The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
|
|
requester address is spelled out (mail clients auto-link both the URL and the address), and the
|
|
reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
|
|
sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
|
|
"""
|
|
body = mailtext.text(
|
|
"admin_new_request.body",
|
|
lang,
|
|
requester=requester,
|
|
link=f"{settings.app_base}/?admin=access-requests",
|
|
)
|
|
subject = mailtext.text("admin_new_request.subject", lang, requester=requester)
|
|
return _send(admins, subject, body, reply_to=requester)
|
|
|
|
|
|
def send_verify_email(to: str, lang: str, link: str, hours: int = 24) -> bool:
|
|
# The body says what the link's rules are. The reset mail always has; this one left the reader to
|
|
# discover both by meeting a dead link, which is precisely the silence this epic removes.
|
|
body = mailtext.text(
|
|
"verify_email.body", lang, link=link, expiry=mailtext.link_expiry(hours, lang)
|
|
)
|
|
return _send([to], mailtext.text("verify_email.subject", lang), body)
|
|
|
|
|
|
def send_password_reset(to: str, lang: str, link: str) -> bool:
|
|
body = mailtext.text("password_reset.body", lang, link=link)
|
|
return _send([to], mailtext.text("password_reset.subject", lang), body)
|
|
|
|
|
|
def send_test(to: str, lang: str) -> bool:
|
|
return _send([to], mailtext.text("test.subject", lang), mailtext.text("test.body", lang))
|