July 7 - Update invitation with Brand Name, based on hostname
This commit is contained in:
@@ -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. |
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user