July 7 - Fix reset link hasn't been sent 2

This commit is contained in:
2026-07-07 13:04:07 -04:00
parent 9b1f57769e
commit a10e314854
4 changed files with 15 additions and 80 deletions
+1 -1
View File
@@ -1104,7 +1104,7 @@ timeout = 30
| 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. | | 61 | **Contract→Facility cascade UI pattern: contract selector is UI-only, not a WTForms field** | The "Log New Issue" form (`issues/form.html`) and both filter bars (`issues/list.html`, `inspections/list.html`) use a plain HTML `<select id="...contract...">` that triggers an AJAX call to `GET /inspections/facilities_for_project/<id>` on change, repopulating the facility dropdown. `IssueForm.facility_id.choices` is always set to ALL active facilities in the route so POST validation passes regardless of which contract was selected in the UI. On POST error re-render, the route derives `selected_project_id` from the submitted `facility_id`'s `project_id` and passes it to the template so JS can restore both selectors. |
| 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. | | 62 | **`issue.resolved_facility.project` and `inspection.facility.project` give the contract** | `Project.facilities` declares `backref='project'`, so `facility.project` is a direct ORM attribute (not a dynamic query). Guard all template accesses: `ins.facility.project.name if ins.facility and ins.facility.project else '—'`. The contract name is displayed in the issues list, issues detail, and inspections list; the issues list also accepts a `contract_id` query param that pre-filters the facility dropdown server-side. |
| 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()` and `issues.index()` build the `projects` list differently for `customer` role: query `CustomerAssignment.query.filter_by(user_id=current_user.id)` to get assigned `project_id` values, then filter `Project` to that set. All other roles still receive all active projects. Pattern mirrors the existing inspector scoping in `inspections.start()`. | | 63 | **Customer Contract filter scoped to assigned contracts only** | `inspections.index()` and `issues.index()` build the `projects` list differently for `customer` role: query `CustomerAssignment.query.filter_by(user_id=current_user.id)` to get assigned `project_id` values, then filter `Project` to that set. All other roles still receive all active projects. Pattern mirrors the existing inspector scoping in `inspections.start()`. |
| 64 | **Invitation email sender and link domain are derived from `request.host_url`** | `_send_invite_email(user, token, base_url=None)` in `customers.py` accepts an optional `base_url`. Both call sites (`invite` and `resend_invite`) pass `request.host_url`. Inside the function, `effective_base` is built from that value (falling back to `APP_BASE_URL`); `setup_link` uses `effective_base`; `sender` is `noreply@<netloc>` parsed from `effective_base`. The SMTP server and credentials are unchanged — only the `From` address and link URL vary per domain. | | 64 | **Invitation / reset email: LINK is per-domain, but `From` MUST be the authenticated `MAIL_DEFAULT_SENDER`** | `_send_invite_email` (`customers.py`) and `_send_password_reset_email` (`auth.py`) accept an optional `base_url` (both call sites pass `request.host_url`), and build `setup_link` / `reset_link` from `effective_base` so the link points at the domain the user is on. **The `From` address, however, is `MAIL_DEFAULT_SENDER` (fallback `MAIL_USERNAME`) — NOT `noreply@<host>`.** Using a per-host `noreply@<netloc>` sender caused mail to be accepted by the relay but silently dropped downstream by SPF/DMARC (the subdomain address is not an authorized sender), so reset/invite emails never arrived while notification emails — which already used `MAIL_DEFAULT_SENDER` — did. Only the link URL varies per domain; the sender is constant and authenticated. Confirmed July 2026 via SMTP A/B test. |
| 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. | | 65 | **Customer "Your Facilities" uses a card grid, not a table** | See §18 "Customer Dashboard — Your Facilities Panel". Never revert to a full-width table for this section. The show-more threshold is `VISIBLE = 9`; the search input threshold is `> 6`. Both thresholds live as JS/Jinja constants in `dashboard.html` and can be adjusted together if needed. |
| 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. | | 66 | **FAQ chip text must use `data-faq` attribute, not `onclick` with `\| tojson`** | `\| tojson` emits `"text"` (double-quoted) inside `onclick="..."` (also double-quoted), breaking HTML parsing and silently truncating the `<script>` block. Use `data-faq="{{ text \| e }}"` and read via `btn.dataset.faq` in JS. |
| 67 | **`display_name` in JS must use `\| tojson`, not inline Jinja interpolation** | `"Hi {{ name }}"` in a JS string literal breaks if `name` contains `"` or `\`. Use `var name = {{ name \| tojson }};` then concatenate. | | 67 | **`display_name` in JS must use `\| tojson`, not inline Jinja interpolation** | `"Hi {{ name }}"` in a JS string literal breaks if `name` contains `"` or `\`. Use `var name = {{ name \| tojson }};` then concatenate. |
+8 -4
View File
@@ -1,5 +1,4 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
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
@@ -403,7 +402,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'):
@@ -412,8 +410,14 @@ 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' # From MUST be the authenticated SMTP identity, otherwise the mail server
sender = f'noreply@{host}' # accepts the message but it is dropped downstream by SPF/DMARC/relay policy
# (a per-host noreply@<domain> is NOT an authorized sender). The per-domain
# host is still reflected in the reset LINK above, preserving rule 64's
# multi-domain intent without breaking deliverability.
sender = (current_app.config.get('MAIL_DEFAULT_SENDER')
or current_app.config.get('MAIL_USERNAME')
or 'noreply@janitorialqc.local')
html_body = render_template_string("""<!DOCTYPE html> html_body = render_template_string("""<!DOCTYPE html>
<html> <html>
+6 -3
View File
@@ -179,7 +179,6 @@ def _send_invite_email(user, token, base_url=None):
from flask import current_app, render_template_string from flask import current_app, render_template_string
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'):
@@ -189,8 +188,12 @@ def _send_invite_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('/')
setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}' setup_link = f'{effective_base}{url_for("customers.set_password", token=token)}'
host = urlparse(effective_base).netloc or 'janitorialqc.local' # From MUST be the authenticated SMTP identity (MAIL_DEFAULT_SENDER); a
sender = f'noreply@{host}' # per-host noreply@<domain> is accepted by the relay but dropped downstream
# by SPF/DMARC. The per-domain host is still preserved in setup_link above.
sender = (current_app.config.get('MAIL_DEFAULT_SENDER')
or current_app.config.get('MAIL_USERNAME')
or 'noreply@janitorialqc.local')
html_body = render_template_string("""<!DOCTYPE html> html_body = render_template_string("""<!DOCTYPE html>
<html> <html>
-72
View File
@@ -1,72 +0,0 @@
"""
test_mail.py one-off SMTP diagnostic. NOT part of the app.
Run on the server, in the app directory, with the venv active:
python test_mail.py your-address@example.com
It prints the live mail config and tries TWO sends with different From
addresses so we can see whether the problem is SMTP itself or the sender
address the reset email uses. Delete this file once diagnosed.
"""
import sys
from app import create_app, mail
from flask_mail import Message
app = create_app('production') # match how gunicorn runs it
to_addr = sys.argv[1] if len(sys.argv) > 1 else None
if not to_addr:
print("Usage: python test_mail.py your-address@example.com")
sys.exit(1)
with app.app_context():
cfg = app.config
print("=" * 60)
print("MAIL_SERVER :", cfg.get("MAIL_SERVER"))
print("MAIL_PORT :", cfg.get("MAIL_PORT"))
print("MAIL_USE_SSL :", cfg.get("MAIL_USE_SSL"))
print("MAIL_USE_TLS :", cfg.get("MAIL_USE_TLS"))
print("MAIL_USERNAME :", cfg.get("MAIL_USERNAME"))
print("MAIL_DEFAULT_SENDER:", cfg.get("MAIL_DEFAULT_SENDER"))
print("APP_BASE_URL :", cfg.get("APP_BASE_URL"))
print("=" * 60)
if not cfg.get("MAIL_SERVER"):
print("STOP: MAIL_SERVER is not set in this environment. "
"No email can be sent at all.")
sys.exit(2)
# Attempt A — send from MAIL_DEFAULT_SENDER (what notification emails use)
default_sender = cfg.get("MAIL_DEFAULT_SENDER") or cfg.get("MAIL_USERNAME")
print(f"\n[A] Sending from MAIL_DEFAULT_SENDER: {default_sender}")
try:
mail.send(Message(
subject="[JQC] Test A (default sender)",
sender=default_sender,
recipients=[to_addr],
body="Test A: sent from MAIL_DEFAULT_SENDER.",
))
print(" [A] RESULT: relay ACCEPTED")
except Exception as e:
print(" [A] RESULT: FAILED ->", repr(e))
# Attempt B — send from noreply@<host> (what the reset/invite email uses)
from urllib.parse import urlparse
base = (cfg.get("APP_BASE_URL") or "").rstrip("/")
host = urlparse(base).netloc or "janitorialqc.local"
noreply_sender = f"noreply@{host}"
print(f"\n[B] Sending from reset-style sender: {noreply_sender}")
try:
mail.send(Message(
subject="[JQC] Test B (noreply sender)",
sender=noreply_sender,
recipients=[to_addr],
body="Test B: sent from noreply@<host>.",
))
print(" [B] RESULT: relay ACCEPTED")
except Exception as e:
print(" [B] RESULT: FAILED ->", repr(e))
print("\nDone. Check the inbox/spam for 'Test A' and 'Test B'.")