Files

3.7 KiB

Email Invite Implementation — Jul 9, 2026

What was built

Commissioners can invite players via email from the league settings page. The invite sends a Sho'Nuff-styled HTML email with the league's invite code and a signed JWT link.

Files changed

server.py (backend)

New models:

  • InviteRequest (Pydantic): {email: str}

New function:

  • send_invite_email(to_email, league_name, commish_name, invite_code, signed_link) builds and sends HTML email via the same SMTP pipeline (mail.germainebrown.com:2525)

New endpoint:

  • POST /api/leagues/{league_id}/invite — commissioner-only, validates league is in draft, not full, then generates a signed JWT invite token (7-day expiry) and sends the email.

Signed token payload:

jwt.encode({
    "league_id": league_id,
    "invite_code": league["invite_code"],
    "exp": datetime.utcnow() + timedelta(days=7),
    "iat": datetime.utcnow(),
}, JWT_SECRET, algorithm="HS256")

Signed link: https://shark.iamgmb.com/?invite={token}

league.html (frontend)

Added HTML:

  • <div id="inviteByEmailSection"> with amber-bordered .settings-card wrapping:
    • Email input (#inviteEmailInput)
    • Send button (#sendInviteBtn)
    • Status div (#inviteStatus)
  • Only visible to commissioner

Added JS functions:

  • renderInviteSection() — called from renderLeague(), shows/hides based on is_commissioner
  • sendInvite() — validates email (non-empty, has @ and .), POSTs to /api/leagues/{id}/invite, shows success/error, clears input on success

Email template

  • Dark ocean background (#0a1628), amber headers, Sho'Nuff signature
  • Prominent invite code in amber monospace (28px, 4px letter-spacing)
  • "Accept Invitation" CTA button (amber gradient)
  • Fallback invite code instructions
  • Random Sho'Nuff title + closing quote (inline lists in server.py)
  • Base64 signature image from /root/.hermes/references/shonuff-image-b64.txt
  • BCC to g@germainebrown.com

Testing

# Register a commish
POST /api/auth/register {"email": "test@example.com", "password": "testpass123", "display_name": "TestCommish"}

# Create league
POST /api/leagues {"name": "Test League", "max_players": 6}

# Send invite
POST /api/leagues/{id}/invite {"email": "g@germainebrown.com"}

Server restart pitfalls

If the old process holds port 8083:

fuser -k 8083/tcp
sleep 1
fuser 8083/tcp  # should print nothing
# then restart

Verify route registered by checking OpenAPI schema or hitting it (expect 401 without auth, not 405):

python3 -c "
import json, urllib.request
resp = urllib.request.urlopen('http://localhost:8083/openapi.json')
data = json.loads(resp.read())
for p in sorted(data.get('paths', {}).keys()):
    if 'invite' in p:
        print(p, ':', list(data['paths'][p].keys()))
"

A 405 "Method Not Allowed" means the old server is running. Kill it and restart.

Known limitations

  1. Invite query param (?invite=...) is not consumed client-side — the landing page at index.html doesn't read the signed token or pre-fill the invite code. Recipients must use the plain invite code manually. The token exists in the URL but the frontend join flow doesn't process it yet.
  2. Inline Sho'Nuff title/closing listssend_invite_email() has hardcoded arrays matching the reference files. If shonuff-titles.py or shonuff-closings.py change, server.py must be updated manually.
  3. No rate limiting — commissioner can hit the endpoint rapidly. SMTP spam protection is at the mail relay level.
  4. No duplicate email detection — same email can be invited multiple times.
  5. No invite history/audit trail — invites are fire-and-forget, no DB record.