""" 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 _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.', '', 'JQC by L.T Services, Inc', ] 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, schema.CORRECTIONS_EMAIL), html = render_template('enrollment/email_confirmation.html', record=record, people=people, schema=schema), ) _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'))