Two user-reported signin bugs:
1) A plain re-login (or logout→login) wiped the user's YouTube grant: login requests
only BASE_SCOPES and Google's returned `scope` can list just those, but _store_token
wrote it verbatim — dropping a previously-granted youtube.readonly/youtube scope, so
can_read flipped to false and the feed demanded a reconnect. The underlying grant
(refresh token) survives such a login, so UNION the scopes instead of narrowing; they
only shrink on full disconnect (purge deletes the token row).
2) Admin 'new access request' email: (a) it named the wrong menu ('Settings → Account'
instead of Users → Access requests); (b) the requester address was invisible so the
'reply reaches them' note looked wrong (Reply-To is in fact set to the requester) —
now spelled out + a deep-link straight to the approve view (?admin=access-requests,
handled in App); (c) it emailed only the static env ADMIN_EMAILS — now notifies the
ACTUAL admins (active role=admin users) unioned with env, so a UI-promoted admin (who
CAN approve — the approve UI is role-gated) is notified too.
188 lines
7.1 KiB
Python
188 lines
7.1 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.
|
|
"""
|
|
import logging
|
|
import smtplib
|
|
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("siftlode.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:
|
|
c = _smtp()
|
|
return bool(c["host"] and c["user"] and c["password"])
|
|
|
|
|
|
def _admin_contact() -> str | None:
|
|
return next(iter(sorted(settings.admin_email_set)), None)
|
|
|
|
|
|
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"]):
|
|
log.info("SMTP not configured; skipping email %r to %s", 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
|
|
msg.set_content(body)
|
|
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.info("Sent email %r to %s", subject, recipients)
|
|
return True
|
|
except Exception:
|
|
log.exception("SMTP send failed (%r to %s)", subject, recipients)
|
|
return False
|
|
|
|
|
|
def send_access_approved(email: str) -> bool:
|
|
admin = _admin_contact()
|
|
body = (
|
|
"Hi,\n\n"
|
|
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
|
|
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
|
|
f"{settings.app_base}\n\n"
|
|
"If you weren't expecting this, you can ignore the message.\n\n"
|
|
"— Siftlode"
|
|
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
|
)
|
|
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
|
|
|
|
|
|
def send_role_changed(to: str, role: str, operator: str | None) -> bool:
|
|
line = (
|
|
"You've been granted admin access on Siftlode — you can now manage settings, the "
|
|
"scheduler and users."
|
|
if role == "admin"
|
|
else "Your admin access on Siftlode has been removed — you're now a regular user."
|
|
)
|
|
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
|
|
body = "Hi,\n\n" + line + contact + "\n\n— Siftlode"
|
|
return _send([to], "Your Siftlode role has changed", body, reply_to=operator)
|
|
|
|
|
|
def send_account_unsuspended(to: str, operator: str | None) -> bool:
|
|
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
|
|
body = (
|
|
"Hi,\n\n"
|
|
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
|
|
"in again."
|
|
+ contact
|
|
+ "\n\n— Siftlode"
|
|
)
|
|
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
|
|
|
|
|
|
def send_account_deleted(to: str, operator: str | None) -> bool:
|
|
contact = (
|
|
f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}."
|
|
if operator
|
|
else ""
|
|
)
|
|
body = (
|
|
"Hi,\n\n"
|
|
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
|
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
|
"Thanks for having used Siftlode."
|
|
+ contact
|
|
+ "\n\n— Siftlode"
|
|
)
|
|
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
|
|
|
|
|
|
def send_account_suspended(to: str, operator: str | None) -> bool:
|
|
contact = (
|
|
f"If you think this is a mistake or have questions, contact the Siftlode operator "
|
|
f"at {operator}.\n\n"
|
|
if operator
|
|
else "If you think this is a mistake, please contact the Siftlode operator.\n\n"
|
|
)
|
|
body = (
|
|
"Hi,\n\n"
|
|
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
|
"suspended, so access was denied.\n\n"
|
|
+ contact
|
|
+ "— Siftlode"
|
|
)
|
|
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
|
|
|
|
|
|
def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
|
# 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.)
|
|
approve_url = f"{settings.app_base}/?admin=access-requests"
|
|
body = (
|
|
f"{requester} just requested access to Siftlode.\n\n"
|
|
f"Approve or deny it here:\n{approve_url}\n"
|
|
f"(or in Siftlode: open Users → Access requests)\n\n"
|
|
f"Requester's email: {requester}\n"
|
|
f"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
|
|
)
|
|
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
|
|
|
|
|
def send_verify_email(to: str, link: str) -> bool:
|
|
body = (
|
|
"Hi,\n\n"
|
|
"Confirm your email to finish creating your Siftlode account:\n\n"
|
|
f"{link}\n\n"
|
|
"If you didn't sign up, you can ignore this message.\n\n"
|
|
"— Siftlode"
|
|
)
|
|
return _send([to], "Confirm your Siftlode email", body)
|
|
|
|
|
|
def send_password_reset(to: str, link: str) -> bool:
|
|
body = (
|
|
"Hi,\n\n"
|
|
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
|
|
f"{link}\n\n"
|
|
"If you didn't request this, you can ignore this message — your password is unchanged.\n\n"
|
|
"— Siftlode"
|
|
)
|
|
return _send([to], "Reset your Siftlode password", body)
|
|
|
|
|
|
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)
|