July 7 - Update invitation with Brand Name, based on hostname

This commit is contained in:
2026-07-07 19:57:46 -04:00
parent a10e314854
commit d0e72af188
3 changed files with 123 additions and 7 deletions
+9 -1
View File
@@ -431,6 +431,14 @@ contract_notification_recipients:
### `audit.py` ### `audit.py`
`log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. **This function calls `db.session.commit()` internally.** Calling it before the primary commit will prematurely persist any dirty ORM state in the session. `log_action(action, entity_type, entity_id, entity_label, details)` — call **after** `db.session.commit()`. **This function calls `db.session.commit()` internally.** Calling it before the primary commit will prematurely persist any dirty ORM state in the session.
### `mail_utils.py`
`branded_sender(base_url=None)` — returns a Flask-Mail `(display_name, address)` sender tuple whose identity tracks the current host, used by the customer invite email. Two things vary independently:
- **Display name** (always applied, zero DNS): per-domain brand from `BRAND_NAMES` (fallback `DEFAULT_BRAND_NAME`), e.g. `"Gov Services QC"`.
- **From address** (gated): the authenticated local part (`jqc.noreply`) with the host's registrable domain — but **only** for the authenticated sender's own domain or a domain listed in `SENDER_AUTHORIZED_DOMAINS`. Every other domain keeps the authenticated `MAIL_DEFAULT_SENDER` address so it still passes SPF/DMARC and delivers.
So with no DNS work an invite from `jqc.govservicesinc.com` sends `From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>` (branded name, deliverable address). After that domain's SPF `include:` + DKIM are live, add it to `SENDER_AUTHORIZED_DOMAINS` and it upgrades to `<jqc.noreply@govservicesinc.com>` — no code change. Falls back to the bare authenticated sender string for unparseable hosts (localhost, empty). Edit `BRAND_NAMES` / `SENDER_AUTHORIZED_DOMAINS` as brands and DNS come online. See rule 64.
### `scope.py` ### `scope.py`
`get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for non-customers. `get_customer_scope(user)` — returns `list[int]` facility IDs for customers, `None` for non-customers.
`get_inspector_scope(user)` — returns `list[int]` facility IDs for inspectors (empty list = no assignments = no access), `None` for non-inspectors. Derived from `InspectorAssignment` rows → project → active facilities. `get_inspector_scope(user)` — returns `list[int]` facility IDs for inspectors (empty list = no assignments = no access), `None` for non-inspectors. Derived from `InspectorAssignment` rows → project → active facilities.
@@ -1104,7 +1112,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 / 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. | | 64 | **Invitation/reset email `From` must be a mail-server-authorized identity; never a bare `noreply@<full-host>`** | Both `_send_invite_email` (`customers.py`) and `_send_password_reset_email` (`auth.py`) take an optional `base_url` (call sites pass `request.host_url`) and build `setup_link`/`reset_link` from `effective_base`, so the **link** always points at the host the user is on. The **`From`** is where deliverability lives: a per-host `noreply@jqc.ltservicesinc.com` (subdomain, wrong local part) was accepted by the relay then silently dropped by SPF/DMARC reset/invite mail never arrived while notification mail (which used `MAIL_DEFAULT_SENDER`) did. Confirmed July 2026 via SMTP A/B test. **Current behaviour:** _reset password_ sends from the fixed authenticated `MAIL_DEFAULT_SENDER` (fallback `MAIL_USERNAME`); _customer invite_ sends from `branded_sender(effective_base)` (see §8 `mail_utils.py`), a `(display_name, address)` tuple. The **display name** is always per-domain (`BRAND_NAMES`), but the **From address** is branded only for the authenticated domain and any `SENDER_AUTHORIZED_DOMAINS` — all other domains keep the authenticated address so they always deliver. This is the deliverable default: `From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>` with zero DNS. To brand the actual address for another domain, set up its SPF `include` + DKIM, then add it to `SENDER_AUTHORIZED_DOMAINS`. **Never** brand an address for a domain lacking SPF/DKIM (accepted-then-dropped, the failure above), and never reintroduce `noreply@<full-host>`. |
| 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. |
+7 -6
View File
@@ -188,12 +188,13 @@ 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)}'
# From MUST be the authenticated SMTP identity (MAIL_DEFAULT_SENDER); a # Per-domain branded From as a (display_name, address) tuple. The display
# per-host noreply@<domain> is accepted by the relay but dropped downstream # NAME always tracks the host (e.g. "Gov Services QC"); the ADDRESS is
# by SPF/DMARC. The per-domain host is still preserved in setup_link above. # branded only for DNS-authorized domains and otherwise stays the
sender = (current_app.config.get('MAIL_DEFAULT_SENDER') # authenticated identity so the mail always delivers. See
or current_app.config.get('MAIL_USERNAME') # app/utils/mail_utils.py and CLAUDE.md rule 64.
or 'noreply@janitorialqc.local') 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>
+107
View File
@@ -0,0 +1,107 @@
"""
app/utils/mail_utils.py
-----------------------
Helpers for constructing outbound email sender identities.
`branded_sender()` returns a Flask-Mail sender as a ``(display_name, address)``
tuple so outbound branded email (currently the customer invitation) shows a
per-domain identity that tracks the host the user is on WITHOUT risking
deliverability.
Two independent things vary by domain:
1. DISPLAY NAME (what the recipient sees, e.g. "Gov Services QC")
Always applied. Costs nothing, needs no DNS. This is the safe default.
2. FROM ADDRESS (the actual @domain, e.g. jqc.noreply@govservicesinc.com)
Only used for domains that are KNOWN to authorize this mail server
(the authenticated sender's own domain, plus anything you add to
SENDER_AUTHORIZED_DOMAINS). Every other domain keeps the authenticated
address so the message still passes SPF/DMARC and delivers.
Result, with no DNS work:
Invited from jqc.govservicesinc.com ->
From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>
(branded NAME, deliverable authenticated ADDRESS)
Once govservicesinc.com's DNS authorizes this mail server (SPF include + DKIM),
add it to SENDER_AUTHORIZED_DOMAINS and it upgrades to:
From: "Gov Services QC" <jqc.noreply@govservicesinc.com>
See CLAUDE.md rule 64.
"""
from urllib.parse import urlparse
from flask import current_app
# ── Per-domain display names (edit as new brands launch) ─────────────────────
# Keyed by registrable domain. A domain not listed here falls back to
# DEFAULT_BRAND_NAME so the From never renders an ugly auto-derived label.
BRAND_NAMES = {
'ltservicesinc.com': 'LT Services QC',
'govservicesinc.com': 'Gov Services QC',
'efs.com': 'EFS QC',
}
DEFAULT_BRAND_NAME = 'Janitorial QC'
# ── Domains cleared to use a BRANDED FROM ADDRESS ────────────────────────────
# Add a registrable domain here ONLY after its DNS authorizes this mail server
# (SPF `include:` + DKIM). Until then the domain still gets its branded display
# NAME but sends from the authenticated address, so it always delivers.
# The authenticated sender's own domain is always treated as authorized and does
# NOT need to be listed here.
SENDER_AUTHORIZED_DOMAINS = {
# 'govservicesinc.com', # ← uncomment once SPF+DKIM are live for it
# 'efs.com',
}
def _authenticated_sender() -> str:
"""The SMTP identity we authenticate as — always deliverable."""
return (current_app.config.get('MAIL_DEFAULT_SENDER')
or current_app.config.get('MAIL_USERNAME')
or 'jqc.noreply@janitorialqc.local')
def _host_domain(base_url: str | None) -> str | None:
"""Registrable domain (last two labels) of base_url, or None if unparseable."""
base = (base_url or current_app.config.get('APP_BASE_URL', '') or '').strip().rstrip('/')
if not base:
return None
netloc = urlparse(base).netloc or urlparse('//' + base).netloc
host = netloc.split('@')[-1].split(':')[0].strip().lower() # drop userinfo/port
if not host or '.' not in host:
return None
labels = [l for l in host.split('.') if l]
return '.'.join(labels[-2:]) if len(labels) >= 2 else host
def branded_sender(base_url: str | None = None):
"""Return a ``(display_name, address)`` sender tuple for branded email.
The display name always tracks the host's domain; the address is branded
only for the authenticated domain and any SENDER_AUTHORIZED_DOMAINS, and
otherwise stays the authenticated address so the mail still delivers.
Falls back to the bare authenticated sender string whenever the host cannot
be parsed (localhost, empty, no dot), so this can never yield an invalid From.
"""
auth_sender = _authenticated_sender()
if '@' not in auth_sender:
return auth_sender
local, auth_domain = auth_sender.rsplit('@', 1)
auth_domain = auth_domain.lower()
domain = _host_domain(base_url)
if not domain:
# Unknown host → safe authenticated address, generic brand name.
return (DEFAULT_BRAND_NAME, auth_sender)
display_name = BRAND_NAMES.get(domain, DEFAULT_BRAND_NAME)
if domain == auth_domain or domain in SENDER_AUTHORIZED_DOMAINS:
address = f'{local}@{domain}' # branded address — DNS-authorized
else:
address = auth_sender # keep deliverable authenticated address
return (display_name, address)