Files
SkinsPins/app/email.py
T
Rolf 8601584099 Add playing handicap, 6-digit login code, and game UX improvements
- Playing handicap: computed at game creation (WHS formula), stored on
  game_players alongside per-hole strokes allocation; displayed on game
  summary with HC card and / markers on scorecard (only when SI defined);
  live game shows +N per player card when SI exists, HC total otherwise;
  new game confirmation previews computed HC per player

- Login: 6-digit code sent alongside magic link in every auth email;
  check_email page has auto-advancing digit inputs with paste support;
  new POST /auth/verify-code route handles code verification

- Game view: Score/Card toggle fixed with position:fixed to always show
  above nav bar on mobile; swipe on score view changes holes, swipe on
  scorecard switches tabs; dirty tracking prevents saving unmodified holes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:37:08 +01:00

105 lines
4.0 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, code: str) -> None:
subject = f"Your {SITE_NAME} login code: {code}"
body_text = (
f"Your login code is: {code}\n\n"
f"Enter it on the app, or click the link below (both expire in 15 minutes):\n\n{link}\n\n"
f"If 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>Enter this code in the app to log in:</p>
<div style="font-size:2.5rem;font-weight:700;letter-spacing:0.4rem;text-align:center;
background:#f0f7f4;border-radius:0.5rem;padding:1rem 1.5rem;margin:1.5rem 0;
color:#1b4332;font-family:monospace;">{code}</div>
<p style="color:#555;font-size:0.9rem;">Or click the button below — whichever is easier. Both expire in 15 minutes.</p>
<p style="margin:1.5rem 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 — code: %s link: %s", to, code, 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)