July 7 - Fix reset link hasn't been sent 2
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'.")
|
|
||||||
Reference in New Issue
Block a user