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`
`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`
`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.
@@ -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. |
| 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()`. |
| 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. |
| 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. |