diff --git a/.claude/settings.json b/.claude/settings.json index 0252436..d28a2c0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,8 @@ "Bash(SECRET_KEY=x DATABASE_URL=sqlite:///:memory: DIGEST_SECRET=x MAIL_SERVER=localhost MAIL_USERNAME=x MAIL_PASSWORD=x MAIL_PORT=587 APP_BASE_URL=http://localhost MAIL_DEFAULT_SENDER=x@x.com python -c ' *)", "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/dashboard.py',encoding='utf-8'\\).read\\(\\)\\); print\\('dashboard route OK'\\)\")", "Bash(python -c ' *)", - "Bash(git diff *)" + "Bash(git diff *)", + "Bash(python -c \"import ast;ast.parse\\(open\\('app/enrollment/routes.py',encoding='utf-8'\\).read\\(\\)\\);print\\('parses OK'\\)\")" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index dad913e..46b3fe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1659,6 +1659,8 @@ If it ever needs to *create* the accounts it describes, do that as a **separate The printed form had six fixed seats (Admin/Director + Inspector 1–5) and a static RECOMMENDATION table for the customer to copy by hand. The web form reworks that: +The header collects Project Name, **Request by**, **Requester email** (required — the confirmation goes there) and Date Requested. The printed sheet's blank *"for office use"* block is **not rendered on the web form** — a customer cannot fill it in; those fields still exist and are filled by staff on the admin detail page. + 1. **Step 1 — the people.** Free-form rows, each with a **role dropdown** (`schema.ROLES`: Admin / Director / Auditor / Inspector / External Inspector), name, job title, email. Starts with one row defaulted to `DEFAULT_FIRST_ROLE`; **"Add another person"** appends more, capped at `MAX_PEOPLE` (25). The last row cannot be removed. 2. **Step 2 — the task matrix**, with **one column per person from Step 1**, rebuilt in the browser whenever a name, role or row changes. Existing ticks survive a rebuild (preserved by field name). 3. **Step 3 — mobile app**, likewise one column per person. @@ -1675,6 +1677,12 @@ A **"Recommendation selection"** button applies `schema.recommendation_map()` pe **Legacy submissions.** Files stored in the original fixed-seat format are never rewritten; `schema.people_of()` / `cell()` / `wants_mobile()` normalise on read, so the admin list, detail view and CSV render both shapes identically — verified against a hand-written legacy file. +### Confirmation email + +`mailer.send_confirmation(record)` emails the requester a copy of what they submitted (reference number, project, and the full people table with roles and mobile-app flags), HTML + plain-text, rendered from `templates/enrollment/email_confirmation.html`. Sent on a background thread (rule 14) with `branded_sender()` as the From (rules 64/76). + +**It cannot cost a customer their enrollment.** It fires *after* `storage.save()` and every failure path is caught and logged: no `MAIL_SERVER`, `mail.send` raising, or the template itself blowing up all still return the normal thank-you page with the record safely on disk — verified for all three. `mailer.py` is the only part of the package touching shared mail infrastructure; it still imports no models and writes no DB row. + ### Storage One JSON document per submission in `ENROLLMENT_DIR`, named `-<8 hex>.json` — time-ordered so a directory listing sorts chronologically, random suffix so two submissions in the same second cannot collide. The stem is the submission id and the **only** thing the admin URLs accept. diff --git a/app/enrollment/mailer.py b/app/enrollment/mailer.py new file mode 100644 index 0000000..c15aa07 --- /dev/null +++ b/app/enrollment/mailer.py @@ -0,0 +1,104 @@ +""" +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 still imports no models and writes no DB row. +""" + +import logging +import threading + +from flask import current_app, render_template + +logger = logging.getLogger(__name__) + + +def _text_body(record, people): + """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.', + '', + 'If anything above is wrong, simply reply to this email and we will ' + 'correct it.', + '', + 'JQC by L.T Services, Inc', + ] + return '\n'.join(lines) + + +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 import mail + 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), + html = render_template('enrollment/email_confirmation.html', + record=record, people=people, + schema=schema), + ) + + app = current_app._get_current_object() + + def _send(): + with app.app_context(): + try: + mail.send(msg) + logger.info('ENROLLMENT EMAIL SENT | to=%s | id=%s', + email, record.get('id')) + except Exception as exc: + logger.error('ENROLLMENT EMAIL FAILED | to=%s | id=%s | error=%s', + email, record.get('id'), exc) + + threading.Thread(target=_send, daemon=True).start() + + 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')) diff --git a/app/enrollment/routes.py b/app/enrollment/routes.py index 5af49d3..9bd2cf3 100644 --- a/app/enrollment/routes.py +++ b/app/enrollment/routes.py @@ -36,7 +36,7 @@ from flask_login import login_required from app import limiter from app.utils.decorators import admin_required -from . import schema, storage +from . import mailer, schema, storage logger = logging.getLogger(__name__) @@ -145,8 +145,9 @@ def submit(): logger.info('ENROLLMENT | honeypot tripped | ip=%s', request.remote_addr) return render_template('enrollment/submitted.html', reference=None) - project_name = _clean(request.form.get('project_name')) - request_by = _clean(request.form.get('request_by')) + project_name = _clean(request.form.get('project_name')) + request_by = _clean(request.form.get('request_by')) + requester_email = _clean(request.form.get('requester_email')) people = _parse_people(request.form) @@ -176,6 +177,11 @@ def submit(): errors.append('Project Name is required.') if not request_by: errors.append('Request by is required.') + if not requester_email: + errors.append('Requester email is required — we send your confirmation ' + 'there.') + elif '@' not in requester_email: + errors.append('The requester email address does not look valid.') if not named: errors.append('Please add at least one person with both a name and an ' 'email address.') @@ -192,8 +198,9 @@ def submit(): seen.add(low) prior = { - 'project_name': project_name, - 'request_by': request_by, + 'project_name': project_name, + 'request_by': request_by, + 'requester_email': requester_email, 'date_requested': _clean(request.form.get('date_requested')), 'notes': _clean(request.form.get('notes'), _MAX_NOTES), 'people': people, @@ -236,7 +243,12 @@ def submit(): logger.info('ENROLLMENT | submitted | id=%s project=%r people=%d ip=%s', record['id'], project_name, len(named), request.remote_addr) - return render_template('enrollment/submitted.html', reference=record['id']) + # Confirmation to the requester. Fired AFTER the save and fully guarded — + # a mail problem must never cost the customer their submission. + mailer.send_confirmation(record, base_url=request.host_url) + + return render_template('enrollment/submitted.html', reference=record['id'], + email=requester_email) # ── Admin ──────────────────────────────────────────────────────────────────── diff --git a/app/enrollment/schema.py b/app/enrollment/schema.py index b54dca5..bdb9259 100644 --- a/app/enrollment/schema.py +++ b/app/enrollment/schema.py @@ -128,7 +128,11 @@ NOTES = [ ] -# ── Office-use fields (filled in by L.T. Services after receipt) ───────────── +# ── Office-use fields ──────────────────────────────────────────────────────── +# Filled in by L.T. Services AFTER receipt, on the admin detail page only. The +# printed sheet showed these to the customer as a blank "for office use" block; +# the web form does not render them at all — a customer cannot fill them in, so +# showing them was only noise. OFFICE_FIELDS = [ ('receive_date', 'Receive Date'), ('program_by', 'Program By'), diff --git a/app/enrollment/templates/enrollment/admin_detail.html b/app/enrollment/templates/enrollment/admin_detail.html index 6c7e631..2de575c 100644 --- a/app/enrollment/templates/enrollment/admin_detail.html +++ b/app/enrollment/templates/enrollment/admin_detail.html @@ -37,6 +37,12 @@
Project Name
{{ record.project_name or '—' }}
Request by
{{ record.request_by or '—' }}
+
Requester email
+
+ {% if record.requester_email %} + {{ record.requester_email }} + {% else %}{% endif %} +
Date Requested
{{ record.date_requested or '—' }}
{% if record.notes %} diff --git a/app/enrollment/templates/enrollment/email_confirmation.html b/app/enrollment/templates/enrollment/email_confirmation.html new file mode 100644 index 0000000..6401251 --- /dev/null +++ b/app/enrollment/templates/enrollment/email_confirmation.html @@ -0,0 +1,75 @@ +{# Confirmation email sent to the requester. Inline styles only and no external + assets — mail clients strip