Files
JQC_multi_tenant/app/utils/mail_utils.py
T

155 lines
6.2 KiB
Python

"""
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 (customer invitations, billing notices) shows a
per-tenant identity — WITHOUT risking deliverability.
Two independent things vary:
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 gov.jqc.app ->
From: "Gov Services QC" <jqc.noreply@ltservicesinc.com>
(branded NAME, deliverable authenticated ADDRESS)
Once a tenant's own domain 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>
Ported from the single-tenant tree with one multi-tenant change: the display
name comes from the TENANT, not a hardcoded host→name dict. Resolution order:
1. TenantSettings.company_name — what the tenant typed into Branding
2. Tenant.name (control plane) — always known for a resolved tenant
3. BRAND_NAMES[domain] — ST's host map, kept for non-tenant hosts
4. DEFAULT_BRAND_NAME
See CLAUDE.md rule 64.
"""
from urllib.parse import urlparse
from flask import current_app, g
# ── Per-domain display names (single-tenant / non-tenant hosts only) ─────────
# In multi-tenant mode the tenant record supplies the name and this map is never
# consulted. It remains for the single-tenant deployment and for hosts that
# resolve to no tenant.
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.
# NOTE: written as set([...]) deliberately. A brace literal containing only
# comments is an empty *dict*, which silently becomes a *set* the moment a line
# is uncommented. Membership works either way, so this is cosmetic — but the
# declared type shouldn't change based on whether a comment is uncommented.
SENDER_AUTHORIZED_DOMAINS = set([
# '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):
"""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 _tenant_brand_name():
"""Display name from the current tenant, or None.
Prefers what the tenant typed into Branding (TenantSettings.company_name),
then the control-plane tenant name. Returns None in single-tenant mode or
when no tenant is bound.
Deliberately swallows errors: TenantSettings.query raises ProgrammingError
when a freshly provisioned tenant DB has not been migrated yet, and an
un-migrated tenant must not break sending mail.
"""
if not current_app.config.get('MULTI_TENANT_ENABLED'):
return None
tenant = getattr(g, 'tenant', None) if g else None
if tenant is None:
return None
try:
from app.models.tenant_settings import TenantSettings
settings = TenantSettings.get_or_default()
if settings is not None:
name = (settings.company_name or '').strip()
if name:
return name
except Exception:
pass
return (getattr(tenant, 'name', '') or '').strip() or None
def branded_sender(base_url=None):
"""Return a ``(display_name, address)`` sender tuple for branded email.
The display name tracks the tenant (falling back to 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()
tenant_name = _tenant_brand_name()
domain = _host_domain(base_url)
if not domain:
# Unknown host → safe authenticated address; still brand the name if a
# tenant is bound.
return (tenant_name or DEFAULT_BRAND_NAME, auth_sender)
display_name = tenant_name or 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)