Add secure password reset email delivery

This commit is contained in:
rob
2026-07-21 12:42:05 -03:00
parent 6a88fe2e0f
commit 9bedf70371
3 changed files with 309 additions and 80 deletions
+81 -73
View File
@@ -1,72 +1,103 @@
"""Email sending utilities for CmdForge web.
"""Transactional email utilities for the CmdForge web application.
In development mode, emails are logged to the console instead of being sent.
To enable real email sending, set MAIL_ENABLED=true and configure SMTP settings.
Production delivery uses Resend's HTTPS API. Email bodies and reset URLs are
never written to logs. Console delivery is an explicit development-only mode.
"""
from __future__ import annotations
import logging
from html import escape
from typing import Optional
import requests
from flask import current_app
logger = logging.getLogger(__name__)
RESEND_ENDPOINT = "https://api.resend.com/emails"
SUPPORTED_TRANSPORTS = {"disabled", "console", "resend"}
def _transport() -> str:
transport = str(current_app.config.get("MAIL_TRANSPORT", "disabled")).strip().lower()
if transport not in SUPPORTED_TRANSPORTS:
logger.error("Unsupported email transport configured; delivery disabled")
return "disabled"
return transport
def send_email(to: str, subject: str, html_body: str, text_body: Optional[str] = None) -> bool:
"""Send an email.
"""Deliver an email without logging its recipient or contents.
In dev mode (MAIL_ENABLED=false or unset), logs the email to console.
In production (MAIL_ENABLED=true), sends via SMTP.
Args:
to: Recipient email address
subject: Email subject
html_body: HTML content of the email
text_body: Plain text content (optional)
Returns:
True if email was sent/logged successfully, False otherwise
``MAIL_TRANSPORT`` must be one of ``disabled``, ``console``, or ``resend``.
Console mode records only that a message was suppressed and reports no
delivery, so reset tokens are invalidated. It is also rejected in
production. Resend mode requires ``RESEND_API_KEY`` and ``MAIL_FROM``.
"""
mail_enabled = current_app.config.get("MAIL_ENABLED", False)
transport = _transport()
environment = str(current_app.config.get("CMDFORGE_ENV", "development")).lower()
if mail_enabled:
# Future: implement real SMTP sending
# smtp_host = current_app.config.get("MAIL_SERVER", "localhost")
# smtp_port = current_app.config.get("MAIL_PORT", 587)
# smtp_user = current_app.config.get("MAIL_USERNAME")
# smtp_pass = current_app.config.get("MAIL_PASSWORD")
# smtp_tls = current_app.config.get("MAIL_USE_TLS", True)
logger.warning("MAIL_ENABLED is true but SMTP is not implemented yet. Falling back to console logging.")
if transport == "disabled":
logger.warning("Email delivery is disabled")
return False
# Dev mode: log to console
logger.info("=" * 60)
logger.info("[EMAIL] To: %s", to)
logger.info("[EMAIL] Subject: %s", subject)
logger.info("[EMAIL] Body:")
if transport == "console":
if environment == "production":
logger.error("Console email transport is not permitted in production")
return False
logger.info("Development email suppressed (subject length=%d)", len(subject))
return False
api_key = str(current_app.config.get("RESEND_API_KEY") or "").strip()
sender = str(current_app.config.get("MAIL_FROM") or "").strip()
if not api_key or not sender:
logger.error("Resend email transport is missing required configuration")
return False
try:
timeout = float(current_app.config.get("MAIL_TIMEOUT", 10.0))
except (TypeError, ValueError):
logger.error("Invalid email timeout configured")
return False
if timeout <= 0 or timeout > 60:
logger.error("Email timeout must be greater than zero and at most 60 seconds")
return False
payload = {
"from": sender,
"to": [to],
"subject": subject,
"html": html_body,
}
if text_body:
logger.info(text_body)
else:
logger.info(html_body)
logger.info("=" * 60)
payload["text"] = text_body
try:
response = requests.post(
RESEND_ENDPOINT,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=timeout,
)
response.raise_for_status()
except requests.RequestException as exc:
# Do not log the response body, recipient, request headers, or message;
# providers can echo sensitive request data in error responses.
logger.error("Transactional email delivery failed (%s)", type(exc).__name__)
return False
logger.info("Transactional email accepted by provider")
return True
def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
"""Send a password reset email.
Args:
to: Recipient email address
token: The password reset token
base_url: Base URL of the application (e.g., https://cmdforge.brrd.tech)
Returns:
True if email was sent/logged successfully
"""
"""Send a one-hour password reset link using the configured transport."""
reset_url = f"{base_url.rstrip('/')}/reset-password?token={token}"
html_reset_url = escape(reset_url, quote=True)
subject = "Reset your CmdForge password"
html_body = f"""
@@ -86,28 +117,15 @@ def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">CmdForge</div>
</div>
<div class="header"><div class="logo">CmdForge</div></div>
<p>Hello,</p>
<p>You requested to reset your password for your CmdForge account. Click the button below to set a new password:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" class="button">Reset Password</a>
</p>
<p style="text-align: center; margin: 30px 0;"><a href="{html_reset_url}" class="button">Reset Password</a></p>
<p>Or copy and paste this link into your browser:</p>
<p class="link">{reset_url}</p>
<p class="link">{html_reset_url}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.</p>
<div class="footer">
<p>This email was sent by CmdForge. If you have questions, visit our website or contact support.</p>
</div>
<div class="footer"><p>This email was sent by CmdForge.</p></div>
</div>
</body>
</html>
@@ -115,20 +133,10 @@ def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
text_body = f"""Reset your CmdForge password
Hello,
You requested to reset your password for your CmdForge account.
Reset your password by visiting this link:
Visit this link to set a new password:
{reset_url}
This link will expire in 1 hour.
If you didn't request this password reset, you can safely ignore this email.
Your password will remain unchanged.
---
CmdForge
This link will expire in 1 hour. If you did not request this reset, ignore this email.
"""
return send_email(to, subject, html_body, text_body)