"""Outbound email over SMTP.
The one place this app puts a message on the wire to a person. It knows nothing
about *why* a mail is sent - a façade builds the subject and body and hands them
here - so the transport is testable and swappable without touching the flows that
use it.
Configuration is read from settings.Config at call time. When SMTP_HOST is unset
the module does not fail: it logs the message and returns False, so local dev and
the test suite exercise the whole sign-up and reset flow with no mail server, and
a developer can follow the link straight from the console. That is the skeleton's
standing rule - an unset config variable means the feature is off, never a crash -
and it is a fallback for a *missing* configuration, not a swallow of a broken one.
A configured host that then errors raises.
Every message here is plain text and deliberately thin. Two of the three are sent
in response to an unauthenticated request naming an address that may not be the
requester's, so they carry a bare link and no account detail: anything else turns
a "forgot password" form into a way to learn things about a stranger's account.
"""
import logging
import smtplib
import ssl
from email.message import EmailMessage
import settings
logger = logging.getLogger(__name__)
def _from_address() -> str:
cfg = settings.Config
return cfg.SMTP_FROM or cfg.SMTP_USER or "no-reply@localhost"
def send(to: str, subject: str, body: str) -> bool:
"""Send a plain-text email. True if it reached an SMTP server, False if it
was logged because none is configured.
Raises on a genuine delivery failure against a configured host. The caller
decides whether that should surface: the reset and verification endpoints
swallow it, because an error that appeared only for real addresses would make
a failing mail server into an account-enumeration oracle.
"""
cfg = settings.Config
msg = EmailMessage()
msg["From"] = _from_address()
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)
if not cfg.SMTP_HOST:
logger.warning("SMTP is not configured; email to %s not sent. "
"Subject: %s\n%s", to, subject, body)
return False
port = int(cfg.SMTP_PORT or 0)
if cfg.SMTP_SSL:
# Implicit TLS from the first byte - the classic port 465.
with smtplib.SMTP_SSL(cfg.SMTP_HOST, port or 465,
context=ssl.create_default_context()) as smtp:
_login_and_send(smtp, msg)
else:
with smtplib.SMTP(cfg.SMTP_HOST, port or 587) as smtp:
if cfg.SMTP_STARTTLS:
smtp.starttls(context=ssl.create_default_context())
_login_and_send(smtp, msg)
logger.info("Sent %r to %s.", subject, to)
return True
def _login_and_send(smtp: smtplib.SMTP, msg: EmailMessage) -> None:
cfg = settings.Config
if cfg.SMTP_USER:
smtp.login(cfg.SMTP_USER, cfg.SMTP_PASS or "")
smtp.send_message(msg)
def send_email_verification(to: str, verify_url: str) -> bool:
"""The link a new sign-up opens to prove they control their inbox."""
body = (
"Confirm this email address to finish setting up your account.\n\n"
f"Open this link within a day to get started:\n\n {verify_url}\n\n"
"If you didn't sign up, you can ignore this email."
)
return send(to, "Confirm your email address", body)
def send_password_reset(to: str, reset_url: str) -> bool:
"""The link behind "forgot password".
Says nothing about the account - not whether one exists, not when it was last
used - because the person who triggered this may not be its owner.
"""
body = (
"Somebody asked to reset the password for this address.\n\n"
f"If it was you, set a new one here within the hour:\n\n {reset_url}\n\n"
"If it wasn't, ignore this email. Nothing has changed and the link will "
"expire on its own."
)
return send(to, "Reset your password", body)
def send_account_approved(to: str, login_url: str) -> bool:
"""Tell somebody their account has been let in.
The other half of the admissions gate, and not optional: an approval nobody
is told about is indistinguishable from a refusal, and the applicant has no
way to find out except by trying the sign-in form again on a hunch.
This one may speak plainly. It is sent by an operator about an account they
were looking at, so there is no stranger typing someone else's address - the
constraint that keeps the two mails above down to a bare link.
"""
body = (
"Good news - your account has been approved.\n\n"
f"You can sign in now:\n\n {login_url}\n\n"
"Use the email address and password you chose when you signed up. If you "
"signed up with Google, use the \"Continue with Google\" button."
)
return send(to, "Your account is ready", body)