2026-06-12 01:43:07 +02:00
|
|
|
"""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
|
2026-06-12 02:05:18 +02:00
|
|
|
from email.utils import formatdate
|
2026-06-12 01:43:07 +02:00
|
|
|
|
2026-06-19 12:22:36 +02:00
|
|
|
from app import sysconfig
|
2026-06-12 01:43:07 +02:00
|
|
|
from app.config import settings
|
2026-06-19 12:22:36 +02:00
|
|
|
from app.db import SessionLocal
|
2026-06-12 01:43:07 +02:00
|
|
|
|
|
|
|
|
log = logging.getLogger("subfeed.email")
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 12:22:36 +02:00
|
|
|
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"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
def email_enabled() -> bool:
|
2026-06-19 12:22:36 +02:00
|
|
|
c = _smtp()
|
|
|
|
|
return bool(c["host"] and c["user"] and c["password"])
|
2026-06-12 01:43:07 +02:00
|
|
|
|
|
|
|
|
|
2026-06-12 02:05:18 +02:00
|
|
|
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:
|
2026-06-12 01:43:07 +02:00
|
|
|
recipients = [e for e in to if e]
|
|
|
|
|
if not recipients:
|
|
|
|
|
return False
|
2026-06-19 12:22:36 +02:00
|
|
|
c = _smtp()
|
|
|
|
|
if not (c["host"] and c["user"] and c["password"]):
|
2026-06-12 01:43:07 +02:00
|
|
|
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
|
|
|
|
|
return False
|
|
|
|
|
msg = EmailMessage()
|
|
|
|
|
msg["Subject"] = subject
|
2026-06-19 12:22:36 +02:00
|
|
|
msg["From"] = c["from"] or c["user"]
|
2026-06-12 01:43:07 +02:00
|
|
|
msg["To"] = ", ".join(recipients)
|
2026-06-12 02:05:18 +02:00
|
|
|
# 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
|
2026-06-12 01:43:07 +02:00
|
|
|
msg.set_content(body)
|
|
|
|
|
try:
|
2026-06-19 12:22:36 +02:00
|
|
|
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
|
2026-06-12 01:43:07 +02:00
|
|
|
s.starttls(context=ssl.create_default_context())
|
2026-06-19 12:22:36 +02:00
|
|
|
s.login(c["user"], c["password"])
|
2026-06-12 01:43:07 +02:00
|
|
|
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:
|
2026-06-12 02:05:18 +02:00
|
|
|
admin = _admin_contact()
|
|
|
|
|
body = (
|
|
|
|
|
"Hi,\n\n"
|
chore: rebrand Subfeed -> Siftlode
Rename all user-facing references (UI wordmark Sift+lode, titles, app name,
legal pages, onboarding wizard, emails, README/docs) and infra paths
(/srv/subfeed -> /srv/siftlode, image tag, deploy script, backup filenames).
Internal identifiers kept on purpose: Postgres user/db "subfeed", logger
namespace, localStorage keys, and the subfeed_pgdata volume (renaming would
orphan the migrated production data).
2026-06-14 04:40:22 +02:00
|
|
|
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
|
|
|
|
|
"Just open Siftlode and sign in with this Google account, and your YouTube\n"
|
2026-06-12 02:05:18 +02:00
|
|
|
"subscriptions feed will be ready.\n\n"
|
|
|
|
|
"If you weren't expecting this, you can ignore the message.\n\n"
|
chore: rebrand Subfeed -> Siftlode
Rename all user-facing references (UI wordmark Sift+lode, titles, app name,
legal pages, onboarding wizard, emails, README/docs) and infra paths
(/srv/subfeed -> /srv/siftlode, image tag, deploy script, backup filenames).
Internal identifiers kept on purpose: Postgres user/db "subfeed", logger
namespace, localStorage keys, and the subfeed_pgdata volume (renaming would
orphan the migrated production data).
2026-06-14 04:40:22 +02:00
|
|
|
"— Siftlode"
|
2026-06-12 02:05:18 +02:00
|
|
|
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
2026-06-12 01:43:07 +02:00
|
|
|
)
|
chore: rebrand Subfeed -> Siftlode
Rename all user-facing references (UI wordmark Sift+lode, titles, app name,
legal pages, onboarding wizard, emails, README/docs) and infra paths
(/srv/subfeed -> /srv/siftlode, image tag, deploy script, backup filenames).
Internal identifiers kept on purpose: Postgres user/db "subfeed", logger
namespace, localStorage keys, and the subfeed_pgdata volume (renaming would
orphan the migrated production data).
2026-06-14 04:40:22 +02:00
|
|
|
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
|
2026-06-12 01:43:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
2026-06-12 02:05:18 +02:00
|
|
|
body = (
|
chore: rebrand Subfeed -> Siftlode
Rename all user-facing references (UI wordmark Sift+lode, titles, app name,
legal pages, onboarding wizard, emails, README/docs) and infra paths
(/srv/subfeed -> /srv/siftlode, image tag, deploy script, backup filenames).
Internal identifiers kept on purpose: Postgres user/db "subfeed", logger
namespace, localStorage keys, and the subfeed_pgdata volume (renaming would
orphan the migrated production data).
2026-06-14 04:40:22 +02:00
|
|
|
f"{requester} just requested access to Siftlode.\n\n"
|
|
|
|
|
"Open Siftlode → Settings → Account → Access requests to approve or deny it.\n\n"
|
2026-06-12 02:05:18 +02:00
|
|
|
"(Reply to this email to reach the requester directly.)\n"
|
2026-06-12 01:43:07 +02:00
|
|
|
)
|
chore: rebrand Subfeed -> Siftlode
Rename all user-facing references (UI wordmark Sift+lode, titles, app name,
legal pages, onboarding wizard, emails, README/docs) and infra paths
(/srv/subfeed -> /srv/siftlode, image tag, deploy script, backup filenames).
Internal identifiers kept on purpose: Postgres user/db "subfeed", logger
namespace, localStorage keys, and the subfeed_pgdata volume (renaming would
orphan the migrated production data).
2026-06-14 04:40:22 +02:00
|
|
|
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
2026-06-19 12:22:36 +02:00
|
|
|
|
|
|
|
|
|
2026-06-19 14:03:11 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 12:22:36 +02:00
|
|
|
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)
|