Files
ds-chat/backend/app/services/email_service.py
T
ksmith b724f8a33b Add admin-invited signups and email notifications (Gitea issue #15)
Site admins can invite a brand-new person by email from the Admin portal
Users tab -- a signup-link email lets them set their own username/password
and lands them in the app already logged in. Existing users invited to a
room now also get an email. Closes the "invited but never notified" gap
from both directions.

SMTP is configured through the Admin Settings tab at runtime (not the env
file), persisted in a new smtp_settings table with the password encrypted
at rest via a Fernet key derived from SESSION_SECRET -- the first
reversible secret this app stores in the database. A "send test email"
button surfaces real delivery errors; the invite/notification paths
themselves never fail loudly, since an SMTP outage shouldn't block an
action that already succeeded in the database.

New site_invites table mirrors RoomInvite's shape but targets an email
address with no room context; the raw signup token is hashed the same way
API tokens are, and only ever exists in the email link. POST /api/signup
is the first genuinely public, unauthenticated account-creation endpoint
in this app, reusing the existing register_user path for identical
validation.
2026-08-14 17:38:56 -06:00

64 lines
2.1 KiB
Python

import logging
from email.message import EmailMessage
import aiosmtplib
from sqlalchemy.ext.asyncio import AsyncSession
from app.crypto import decrypt
from app.models import SmtpSettings
from app.services.smtp_settings_service import get_smtp_settings
logger = logging.getLogger(__name__)
class SmtpNotConfiguredError(Exception):
pass
async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str) -> None:
"""Raises on failure -- internal helper only. Callers decide whether to
swallow (send_email) or surface (send_test_email) the error."""
message = EmailMessage()
message["From"] = cfg.from_address
message["To"] = to_address
message["Subject"] = subject
message.set_content(body)
password = decrypt(cfg.password_encrypted) if cfg.password_encrypted else None
await aiosmtplib.send(
message,
hostname=cfg.host,
port=cfg.port,
username=cfg.username or None,
password=password,
use_tls=cfg.use_tls,
)
async def send_email(db: AsyncSession, to_address: str, subject: str, body: str) -> None:
"""Best-effort -- used by invite/notification flows. Never raises: an
SMTP outage or missing configuration must never block an action (an
invite, a room membership) that already succeeded in the database."""
cfg = await get_smtp_settings(db)
if cfg is None:
logger.debug("SMTP not configured; skipping email to %s", to_address)
return
try:
await _deliver(cfg, to_address, subject, body)
except Exception:
logger.warning("Failed to send email to %s", to_address, exc_info=True)
async def send_test_email(db: AsyncSession, to_address: str) -> None:
"""Used only by the admin 'send test email' button -- raises so the
admin UI can show why it failed instead of a silent no-op."""
cfg = await get_smtp_settings(db)
if cfg is None:
raise SmtpNotConfiguredError()
await _deliver(
cfg,
to_address,
"KeepItTalking test email",
"This is a test email from KeepItTalking to confirm your SMTP settings are working.",
)