Add full SkinsPins application
- 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>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user