8601584099
- 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>
105 lines
4.0 KiB
Python
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)
|