siftlode/backend/app/email.py
npeter83 08137f0952 fix(email): warmer onboarding copy + Reply-To/Date headers
Personalize the access-request and approval emails, add a real Date header and a
Reply-To (approval -> admin, new-request -> requester) so they read as a
conversation rather than a no-reply blast — small nudges for deliverability;
sender reputation still does most of the work.
2026-06-12 02:05:18 +02:00

75 lines
2.9 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.config import settings
log = logging.getLogger("subfeed.email")
def email_enabled() -> bool:
return bool(settings.smtp_host and settings.smtp_user and settings.smtp_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
if not email_enabled():
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["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(settings.smtp_host, settings.smtp_port, timeout=20) as s:
s.starttls(context=ssl.create_default_context())
s.login(settings.smtp_user, settings.smtp_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 Subfeed has been approved — welcome aboard.\n\n"
"Just open Subfeed and sign in with this Google account, and your YouTube\n"
"subscriptions feed will be ready.\n\n"
"If you weren't expecting this, you can ignore the message.\n\n"
"— Subfeed"
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
)
return _send([email], "You're in — your Subfeed access is approved", body, reply_to=admin)
def send_admin_new_request(admins: list[str], requester: str) -> bool:
body = (
f"{requester} just requested access to Subfeed.\n\n"
"Open Subfeed → Settings → Account → Access requests to approve or deny it.\n\n"
"(Reply to this email to reach the requester directly.)\n"
)
return _send(admins, f"Subfeed access request from {requester}", body, reply_to=requester)