diff --git a/config.py b/config.py index 77448bb..420cd80 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv" DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}" ELIGIBLE_MIN_SESSIONS = 3 -APP_VERSION = "2.5.14" +APP_VERSION = "2.5.16" def load_local_env(env_path: Path) -> None: diff --git a/emails.py b/emails.py index 1830f4c..5e68be0 100644 --- a/emails.py +++ b/emails.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +from html import escape import socket from flask import current_app @@ -26,15 +27,95 @@ def _send_message(msg: Message) -> None: socket.setdefaulttimeout(previous_timeout) +def _html_email(title: str, intro: str, cta_label: str | None = None, cta_url: str | None = None, code: str | None = None, note: str | None = None) -> str: + app_url = current_app.config.get("APP_BASE_URL", "https://myboker.org").rstrip("/") + cta_html = "" + if cta_label and cta_url: + cta_html = f""" + + + {escape(cta_label)} + + + + + If the button does not work, copy and paste this link:
+ {escape(cta_url)} + + + """ + + code_html = "" + if code: + code_html = f""" + + +
{escape(code)}
+ + + """ + + note_html = "" + if note: + note_html = f""" + + {escape(note)} + + """ + + return f""" + + + + + + +
+ + + + + + + + + + + {code_html} + {cta_html} + {note_html} + + + +
+
myboker.org
+
+

{escape(title)}

+
{escape(intro)}
+ This email was sent by myboker.org. If you did not request this, you can safely ignore it. +
+
+ +""" + + def send_password_reset(to_email: str, reset_url: str) -> None: + body = ( + f"You requested a password reset for your myboker.org account.\n\n" + f"Reset your password:\n\n" + f"{reset_url}\n\n" + f"This link expires in 1 hour. If you did not request this, you can ignore this email." + ) msg = Message( subject="Reset your myboker.org password", recipients=[to_email], - body=( - f"You requested a password reset for your myboker.org account.\n\n" - f"Click the link below to set a new password:\n\n" - f"{reset_url}\n\n" - f"This link expires in 1 hour. If you did not request this, you can ignore this email." + body=body, + html=_html_email( + "Reset your password", + "We received a request to reset the password for your myboker.org account.", + cta_label="Reset password", + cta_url=reset_url, + note="This link expires in 1 hour.", ), sender=current_app.config.get("MAIL_DEFAULT_SENDER"), ) @@ -42,13 +123,20 @@ def send_password_reset(to_email: str, reset_url: str) -> None: def send_email_verification_code(to_email: str, code: str) -> None: + body = ( + "Verify your myboker.org account with this code:\n\n" + f"{code}\n\n" + "This code expires in 15 minutes. If you did not create an account, you can ignore this email." + ) msg = Message( subject="Your myboker.org verification code", recipients=[to_email], - body=( - "Verify your myboker.org account with this code:\n\n" - f"{code}\n\n" - "This code expires in 15 minutes. If you did not create an account, you can ignore this email." + body=body, + html=_html_email( + "Verify your email", + "Enter this verification code to finish creating your myboker.org account.", + code=code, + note="This code expires in 15 minutes. Do not share it with anyone.", ), sender=current_app.config.get("MAIL_DEFAULT_SENDER"), ) @@ -57,14 +145,22 @@ def send_email_verification_code(to_email: str, code: str) -> None: def send_league_invite(to_email: str, league_name: str, invite_url: str, invited_by_email: str, role: str) -> None: role_label = role.strip().lower() if role else "member" + body = ( + f"{invited_by_email} has invited you to join {league_name} as a {role_label} on myboker.org.\n\n" + f"Accept your invitation:\n\n" + f"{invite_url}\n\n" + f"This link expires in 7 days. You'll need a myboker.org account with this email address to accept." + ) msg = Message( subject=f"You've been invited to join {league_name} on myboker.org", recipients=[to_email], - body=( - f"{invited_by_email} has invited you to join {league_name} as a {role_label} on myboker.org.\n\n" - f"Accept your invitation:\n\n" - f"{invite_url}\n\n" - f"This link expires in 7 days. You'll need a myboker.org account with this email address to accept." + body=body, + html=_html_email( + "You have a league invite", + f"{invited_by_email} invited you to join {league_name} as a {role_label}.", + cta_label="Accept invite", + cta_url=invite_url, + note="This invitation link expires in 7 days.", ), sender=current_app.config.get("MAIL_DEFAULT_SENDER"), ) diff --git a/routes/account.py b/routes/account.py index 16d951a..d82fb22 100644 --- a/routes/account.py +++ b/routes/account.py @@ -117,6 +117,7 @@ def register(): email = normalize_email(form["email"]) password = request.form.get("password", "") confirm_password = request.form.get("confirm_password", "") + existing_user = find_user_by_email(email) if not email or "@" not in email: flash("Enter a valid email address.", "error") @@ -124,10 +125,18 @@ def register(): flash("Password must be at least 8 characters.", "error") elif password != confirm_password: flash("Passwords do not match.", "error") - elif find_user_by_email(email): + elif existing_user and existing_user.disabled_at is None: flash("An account already exists for that email.", "error") else: - user = create_user(email, password) + if existing_user: + user = existing_user + user.password_hash = hash_password(password) + user.disabled_at = None + user.email_verified_at = None + user.email_verification_code_hash = None + user.email_verification_sent_at = None + else: + user = create_user(email, password) db.session.flush() return redirect(_start_email_verification(user, safe_next_url(url_for("leagues.new"))))