Jul 29 - Hotfix auth email sender

This commit is contained in:
2026-07-29 17:20:04 -04:00
parent 321965038d
commit 7c064b6ca1
2 changed files with 31 additions and 10 deletions
+30 -9
View File
@@ -1,5 +1,4 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, session from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, session
from urllib.parse import urlparse
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from app import db, limiter from app import db, limiter
from app.models.user import User from app.models.user import User
@@ -199,7 +198,7 @@ def profile():
if form.validate_on_submit(): if form.validate_on_submit():
current_user.full_name = form.full_name.data.strip() or None current_user.full_name = form.full_name.data.strip() or None
current_user.email = form.email.data current_user.email = form.email.data.strip().lower()
if form.new_password.data: if form.new_password.data:
current_user.set_password(form.new_password.data) current_user.set_password(form.new_password.data)
@@ -289,7 +288,7 @@ def create_user():
user = User( user = User(
username=form.username.data, username=form.username.data,
full_name=form.full_name.data.strip() or None, full_name=form.full_name.data.strip() or None,
email=form.email.data, email=form.email.data.strip().lower(),
role=role role=role
) )
user.set_password(form.password.data) user.set_password(form.password.data)
@@ -323,7 +322,7 @@ def edit_user(user_id):
if form.validate_on_submit(): if form.validate_on_submit():
user.username = form.username.data user.username = form.username.data
user.full_name = form.full_name.data.strip() or None user.full_name = form.full_name.data.strip() or None
user.email = form.email.data user.email = form.email.data.strip().lower()
if not director_editing: if not director_editing:
user.role = form.role.data user.role = form.role.data
@@ -536,7 +535,6 @@ def _send_password_reset_email(user, token, base_url=None):
from flask import current_app, render_template_string, url_for as _url_for from flask import current_app, render_template_string, url_for as _url_for
from flask_mail import Message from flask_mail import Message
from app import mail from app import mail
from urllib.parse import urlparse
import threading import threading
if not current_app.config.get('MAIL_SERVER'): if not current_app.config.get('MAIL_SERVER'):
@@ -545,8 +543,17 @@ def _send_password_reset_email(user, token, base_url=None):
effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/') effective_base = (base_url or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}' reset_link = f'{effective_base}{_url_for("auth.reset_password", token=token)}'
host = urlparse(effective_base).netloc or 'janitorialqc.local'
sender = f'noreply@{host}' # Branded From as a (display_name, address) tuple, exactly as
# customers._send_invite_email does. The display NAME tracks the tenant; the
# ADDRESS is branded only for DNS-authorized domains and otherwise stays the
# authenticated SMTP identity so the mail still delivers. The previous
# `noreply@{host}` sent from whatever host the browser was on, which is not
# an authorized sender for that domain — the mail server accepted it and it
# was then dropped downstream by SPF/DMARC. See app/utils/mail_utils.py and
# CLAUDE.md rule 64.
from app.utils.mail_utils import branded_sender
sender = branded_sender(effective_base)
html_body = render_template_string("""<!DOCTYPE html> html_body = render_template_string("""<!DOCTYPE html>
<html> <html>
@@ -612,12 +619,26 @@ def forgot_password():
form = ForgotPasswordForm() form = ForgotPasswordForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.strip().lower()).first() # Explicit case-insensitive lookup. This is NOT fixing a live bug: no
# table here declares a COLLATE, so `users.email` inherits the utf8mb4
# default (utf8mb4_general_ci / utf8mb4_0900_ai_ci), both of which are
# case-insensitive — a bare `== lower(input)` already matched a
# mixed-case stored address. The point is to stop depending on that
# server default: under a binary/_bin collation the bare comparison
# would silently find nothing and still show the success message below.
email_input = form.email.data.strip().lower()
user = User.query.filter(
db.func.lower(User.email) == email_input
).first()
if user and user.active: if user and user.active:
token = user.generate_set_password_token(expires_hours=1) token = user.generate_set_password_token(expires_hours=1)
db.session.commit() db.session.commit()
_send_password_reset_email(user, token, base_url=request.host_url) _send_password_reset_email(user, token, base_url=request.host_url)
logger.info('AUTH | forgot_password | user=%s | email=%s', user.username, user.email) logger.info('AUTH | forgot_password | reset link dispatched | user=%s | email=%s',
user.username, user.email)
else:
# No leak to the user (generic message below), but log for diagnosis.
logger.info('AUTH | forgot_password | no active account for email=%s', email_input)
# Always show the same message — never reveal whether the email exists # Always show the same message — never reveal whether the email exists
flash( flash(
'If an account with that email address exists, a password reset link ' 'If an account with that email address exists, a password reset link '
+1 -1
View File
@@ -333,7 +333,7 @@ def edit(customer_id):
if form.validate_on_submit(): if form.validate_on_submit():
customer.username = form.username.data customer.username = form.username.data
customer.full_name = form.full_name.data.strip() or None customer.full_name = form.full_name.data.strip() or None
customer.email = form.email.data customer.email = form.email.data.strip().lower()
if form.password.data: if form.password.data:
customer.set_password(form.password.data) customer.set_password(form.password.data)
db.session.commit() db.session.commit()