3a1da1482c
- FastAPI + SQLite + Alpine.js + HTMX PWA for Bingo Bango Bongo golf scoring - Passwordless magic link auth with email (aiosmtplib) and admin invite system - Real-time score entry via WebSocket with drag-and-drop player ordering - Course management with per-hole par and stroke index - Scorecard with golf score markers (birdie, eagle, bogey, etc.) - Two-step new game form with tee selection per player - Dashboard with stats, live games social stream, and recent game history - Game summary view (read-only scorecard with leaderboard) - User profile pages with win stats - PWA install support with generated PNG icons - Admin panel for users, courses, games, and invitations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
97 lines
3.6 KiB
Python
97 lines
3.6 KiB
Python
"""Async email sending via SMTP. Falls back to logging if SMTP is not configured."""
|
|
import logging
|
|
import os
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
|
|
import aiosmtplib
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
|
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
|
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
|
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER)
|
|
SMTP_SSL = os.getenv("SMTP_SSL", "false").lower() == "true"
|
|
SMTP_TLS = os.getenv("SMTP_TLS", "true").lower() != "false"
|
|
|
|
SITE_NAME = "Skins & Pins"
|
|
|
|
|
|
def _configured() -> bool:
|
|
return bool(SMTP_HOST and SMTP_USER and SMTP_FROM)
|
|
|
|
|
|
async def _send(to: str, subject: str, body_text: str, body_html: str) -> None:
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = f"{SITE_NAME} <{SMTP_FROM}>"
|
|
msg["To"] = to
|
|
msg.attach(MIMEText(body_text, "plain"))
|
|
msg.attach(MIMEText(body_html, "html"))
|
|
|
|
await aiosmtplib.send(
|
|
msg,
|
|
hostname=SMTP_HOST,
|
|
port=SMTP_PORT,
|
|
username=SMTP_USER,
|
|
password=SMTP_PASSWORD,
|
|
use_tls=SMTP_SSL,
|
|
start_tls=(SMTP_TLS and not SMTP_SSL),
|
|
timeout=10,
|
|
)
|
|
|
|
|
|
async def send_magic_link(to: str, link: str) -> None:
|
|
subject = f"Your {SITE_NAME} login link"
|
|
body_text = f"Click the link below to log in (expires in 15 minutes):\n\n{link}\n\nIf you didn't request this, you can ignore this email."
|
|
body_html = f"""
|
|
<div style="font-family:sans-serif;max-width:480px;margin:auto;">
|
|
<h2 style="color:#2d6a4f;">⛳ {SITE_NAME}</h2>
|
|
<p>Click the button below to log in. This link expires in 15 minutes.</p>
|
|
<p style="margin:2rem 0;">
|
|
<a href="{link}"
|
|
style="background:#2d6a4f;color:#fff;padding:0.75rem 1.5rem;border-radius:0.5rem;text-decoration:none;font-weight:600;">
|
|
Log in to {SITE_NAME}
|
|
</a>
|
|
</p>
|
|
<p style="color:#888;font-size:0.85rem;">If the button doesn't work, copy this link:<br>{link}</p>
|
|
<p style="color:#888;font-size:0.85rem;">If you didn't request this, you can ignore this email.</p>
|
|
</div>"""
|
|
|
|
if _configured():
|
|
try:
|
|
await _send(to, subject, body_text, body_html)
|
|
logger.info("Magic link email sent to %s", to)
|
|
except Exception as e:
|
|
logger.error("Failed to send magic link email to %s: %s", to, e)
|
|
else:
|
|
logger.info("Magic link for %s: %s", to, link)
|
|
|
|
|
|
async def send_invitation(to: str, invited_by_name: str, login_url: str) -> None:
|
|
subject = f"You've been invited to {SITE_NAME}"
|
|
body_text = f"{invited_by_name} has invited you to join {SITE_NAME}.\n\nLog in here:\n{login_url}"
|
|
body_html = f"""
|
|
<div style="font-family:sans-serif;max-width:480px;margin:auto;">
|
|
<h2 style="color:#2d6a4f;">⛳ {SITE_NAME}</h2>
|
|
<p><strong>{invited_by_name}</strong> has invited you to join {SITE_NAME} — a golf score tracking app.</p>
|
|
<p style="margin:2rem 0;">
|
|
<a href="{login_url}"
|
|
style="background:#2d6a4f;color:#fff;padding:0.75rem 1.5rem;border-radius:0.5rem;text-decoration:none;font-weight:600;">
|
|
Accept invitation
|
|
</a>
|
|
</p>
|
|
<p style="color:#888;font-size:0.85rem;">If the button doesn't work, copy this link:<br>{login_url}</p>
|
|
</div>"""
|
|
|
|
if _configured():
|
|
try:
|
|
await _send(to, subject, body_text, body_html)
|
|
logger.info("Invitation email sent to %s", to)
|
|
except Exception as e:
|
|
logger.error("Failed to send invitation email to %s: %s", to, e)
|
|
else:
|
|
logger.info("Invitation for %s — login at: %s", to, login_url)
|