230 lines
8.7 KiB
Python
230 lines
8.7 KiB
Python
"""
|
|
app/enrollment/mailer.py
|
|
------------------------
|
|
The enrollment confirmation email.
|
|
|
|
Sent to the requester after a submission is stored. One job, and it must never
|
|
be able to break that: the record is already safely on disk before this runs,
|
|
so every failure path here is logged and swallowed. A bounced confirmation must
|
|
not cost the customer their enrollment.
|
|
|
|
Sending happens on a background thread (rule 14 — never block the HTTP
|
|
response), and the From identity comes from branded_sender() so it stays an
|
|
SMTP-authorized address that actually delivers (rules 64 / 76).
|
|
|
|
This is the only part of app/enrollment that touches shared mail
|
|
infrastructure. It performs exactly ONE database read — resolving the active
|
|
admin accounts to notify — and no write. That read is a deliberate, narrowed
|
|
exception to the package's no-models rule (rule 88): the alternative, a
|
|
hand-maintained recipient list in config, drifts out of step with reality the
|
|
first time someone joins or leaves. Everything else here stays model-free.
|
|
"""
|
|
|
|
import logging
|
|
import threading
|
|
|
|
from flask import current_app, render_template
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _tenant_branding():
|
|
"""(display_name, support_email) for the calling tenant.
|
|
|
|
MT: ST hardcodes its own company name and a personal corrections address in
|
|
this module and in schema.py. Sending either to another tenant's customers
|
|
would be wrong and confusing, so both are resolved from TenantSettings at
|
|
send time, falling back to neutral defaults.
|
|
|
|
Best-effort: any failure returns the defaults rather than blocking the
|
|
email, matching the rest of this module's never-raise contract.
|
|
"""
|
|
name, support = 'Janitorial QC', None
|
|
try:
|
|
from app.models.tenant_settings import TenantSettings
|
|
row = TenantSettings.query.first()
|
|
if row is not None:
|
|
name = row.display_name or name
|
|
support = row.support_email or None
|
|
except Exception:
|
|
logger.debug('ENROLLMENT | tenant branding unavailable, using defaults')
|
|
if not support:
|
|
support = (current_app.config.get('ENROLLMENT_CORRECTIONS_EMAIL')
|
|
or current_app.config.get('MAIL_DEFAULT_SENDER') or '')
|
|
return name, support
|
|
|
|
|
|
def _text_body(record, people, corrections_email):
|
|
"""Plain-text alternative — some recipients see only this."""
|
|
lines = [
|
|
f'Hi {record.get("request_by") or "there"},',
|
|
'',
|
|
'Thank you — we have received your JQC enrollment form.',
|
|
'',
|
|
f'Reference: {record.get("id")}',
|
|
f'Project: {record.get("project_name")}',
|
|
'',
|
|
f'People to be set up ({len(people)}):',
|
|
]
|
|
for i, person in enumerate(people, start=1):
|
|
lines.append(
|
|
f' {i}. {person["name"]} — {person["role_label"]} — {person["email"]}'
|
|
)
|
|
lines += [
|
|
'',
|
|
'Our team will create these accounts. Each person will receive their own '
|
|
'email invitation with sign-in instructions.',
|
|
'',
|
|
f'If anything above is wrong, simply send an email to '
|
|
f'{corrections_email}, and we will correct it.',
|
|
'',
|
|
_tenant_branding()[0],
|
|
]
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def _dispatch(msg, label, record):
|
|
"""Send one message on a background thread. Never raises.
|
|
|
|
Rule 14 — the HTTP response must not wait on SMTP. The submission is
|
|
already on disk by the time anything here runs, so a mail failure is
|
|
logged and dropped rather than surfaced to the customer.
|
|
"""
|
|
app = current_app._get_current_object()
|
|
|
|
def _send():
|
|
with app.app_context():
|
|
try:
|
|
from app import mail
|
|
mail.send(msg)
|
|
logger.info('ENROLLMENT %s SENT | to=%s | id=%s',
|
|
label, msg.recipients, record.get('id'))
|
|
except Exception as exc:
|
|
logger.error('ENROLLMENT %s FAILED | to=%s | id=%s | error=%s',
|
|
label, msg.recipients, record.get('id'), exc)
|
|
|
|
threading.Thread(target=_send, daemon=True).start()
|
|
|
|
|
|
def _admin_recipients():
|
|
"""Addresses to alert when a new enrollment arrives.
|
|
|
|
Active `admin` accounts, plus any extra addresses in the optional
|
|
ENROLLMENT_NOTIFY_EMAILS config (comma-separated) for people who should be
|
|
told but do not hold a JQC login. Deduplicated case-insensitively.
|
|
|
|
The User import is function-local and read-only — see the module docstring.
|
|
"""
|
|
emails = []
|
|
try:
|
|
from app.models.user import User
|
|
rows = User.query.filter(User.role == 'admin',
|
|
User.active == True).all() # noqa: E712
|
|
emails += [u.email for u in rows if u.email]
|
|
except Exception:
|
|
# A DB problem must not stop the confirmation going out, nor the
|
|
# submission from succeeding.
|
|
logger.exception('ENROLLMENT | could not resolve admin recipients')
|
|
|
|
extra = current_app.config.get('ENROLLMENT_NOTIFY_EMAILS') or ''
|
|
emails += [e.strip() for e in extra.split(',') if e.strip()]
|
|
|
|
seen, out = set(), []
|
|
for e in emails:
|
|
low = e.lower()
|
|
if low not in seen:
|
|
seen.add(low)
|
|
out.append(e)
|
|
return out
|
|
|
|
|
|
def send_admin_notification(record, base_url=None):
|
|
"""Alert JQC admins that a new enrollment form has arrived. Never raises."""
|
|
if not current_app.config.get('MAIL_SERVER'):
|
|
logger.warning('ENROLLMENT ADMIN EMAIL SKIPPED | no MAIL_SERVER | id=%s',
|
|
record.get('id'))
|
|
return
|
|
|
|
try:
|
|
from flask_mail import Message
|
|
from app.utils.mail_utils import branded_sender
|
|
from . import schema
|
|
|
|
recipients = _admin_recipients()
|
|
if not recipients:
|
|
logger.warning('ENROLLMENT | no admin recipients for id=%s',
|
|
record.get('id'))
|
|
return
|
|
|
|
effective_base = (base_url
|
|
or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
|
people = schema.people_of(record)
|
|
link = f'{effective_base}/enrollment/admin/{record.get("id")}'
|
|
|
|
lines = [
|
|
'A new JQC enrollment form has been submitted.',
|
|
'',
|
|
f'Project: {record.get("project_name")}',
|
|
f'Requester: {record.get("request_by")} <{record.get("requester_email")}>',
|
|
f'Reference: {record.get("id")}',
|
|
f'People: {len(people)}',
|
|
'',
|
|
f'Open it here: {link}',
|
|
]
|
|
if record.get('notes'):
|
|
lines += ['', f'Customer notes: {record["notes"]}']
|
|
|
|
msg = Message(
|
|
subject = f'[JQC] New enrollment — {record.get("project_name")}',
|
|
sender = branded_sender(effective_base),
|
|
recipients = recipients,
|
|
body = '\n'.join(lines),
|
|
html = render_template('enrollment/email_admin_notice.html',
|
|
record=record, people=people,
|
|
schema=schema, link=link),
|
|
)
|
|
_dispatch(msg, 'ADMIN EMAIL', record)
|
|
|
|
except Exception:
|
|
logger.exception('ENROLLMENT ADMIN EMAIL BUILD FAILED | id=%s',
|
|
record.get('id'))
|
|
|
|
|
|
def send_confirmation(record, base_url=None):
|
|
"""Email the requester a copy of what they submitted. Never raises."""
|
|
email = (record.get('requester_email') or '').strip()
|
|
if not email:
|
|
return
|
|
|
|
if not current_app.config.get('MAIL_SERVER'):
|
|
logger.warning('ENROLLMENT EMAIL SKIPPED | no MAIL_SERVER | id=%s',
|
|
record.get('id'))
|
|
return
|
|
|
|
try:
|
|
from flask_mail import Message
|
|
from app.utils.mail_utils import branded_sender
|
|
from . import schema
|
|
|
|
effective_base = (base_url
|
|
or current_app.config.get('APP_BASE_URL', '')).rstrip('/')
|
|
people = schema.people_of(record)
|
|
|
|
msg = Message(
|
|
subject = f'[JQC] Enrollment received — {record.get("project_name")}',
|
|
sender = branded_sender(effective_base),
|
|
recipients = [email],
|
|
body = _text_body(record, people, _tenant_branding()[1]),
|
|
html = render_template('enrollment/email_confirmation.html',
|
|
record=record, people=people,
|
|
schema=schema,
|
|
corrections_email=_tenant_branding()[1]),
|
|
)
|
|
|
|
_dispatch(msg, 'EMAIL', record)
|
|
|
|
except Exception:
|
|
# Building the message failed (bad template, mail misconfigured, …).
|
|
# The submission is already saved — log it and move on.
|
|
logger.exception('ENROLLMENT EMAIL BUILD FAILED | id=%s', record.get('id'))
|