Targeted fixes for issues raised by code review on commit 37639a7
(B-2.3 DictIA email rebrand). All fixes verified against the Windows
manual driver: 16/16 tests pass (12 pre-existing + 4 new regression).
Critical:
- C1 Stored XSS in transactional emails: user.name (validated only on
Length(max=49), no character class) was rendered raw into the f-string
HTML body of verification + reset emails. Added html.escape on the
HTML branch; text body keeps the raw string (no XSS surface). Also
hardened the fallback chain to ((name or '').strip() or username or
'utilisateur').strip() so a None/whitespace name never produces
'Bonjour ,'.
- C2 Reflected XSS in templates/auth/check_email.html: the email value
from request.form was concatenated with literal '<strong>' tags then
fed through | safe, defeating Jinja's autoescape. Split the string so
template-author HTML stays literal and {{ email }} is autoescaped.
Used   for NBSP instead of '1 heure' | safe (more readable).
Important:
- I1 Dropped {{ message | safe }} on flash blocks in
forgot_password.html and reset_password.html (matches check_email.html).
No XSS today (flashes are static literals) but removes the landmine.
- I2 Password reset token replay: URLSafeTimedSerializer is stateless,
so the same valid link could be clicked twice within the 1h window.
Added a check that user.password_reset_token == token after the user
lookup — runs before BOTH GET (form render) and POST (password update).
The existing 'user.password_reset_token = None' on success now
actually invalidates the token.
- I5 MIMEText defaults to us-ascii, which Q-encodes accented French
characters and produces mojibake in some clients. Added explicit
'utf-8' charset on both text and html parts in _send_email.
New regression tests (tests/test_email_service_dictia.py):
- test_verification_email_falls_back_when_name_is_whitespace (I4)
- test_verification_email_handles_unicode_name (I5)
- test_verification_email_escapes_html_in_user_name (C1)
- test_check_email_template_escapes_email_in_response (C2)
Out of scope (per review): M1 (already addressed via solid-color
fallback), M2 (datetime.utcnow — pre-existing, separate cleanup),
M3 (Windows test driver — documented in tests file docstring),
M4-M6 (deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebrand src/services/email.py IN PLACE: French + DictIA + brand gradient
(#0062ff/#00bdd8/#00c896) — replaces legacy "Speakr" / #2563eb. Greetings now
use user.name with fallback to user.username. Subjects:
"Vérifiez votre courriel — DictIA" + "Réinitialiser votre mot de passe — DictIA".
SMTP_FROM_NAME defaults to DictIA. Footer points to info@dictia.ca with the
Loi 25 tagline.
Refonte 4 auth templates IN PLACE pour étendre marketing/base.html : check_email,
forgot_password, reset_password, verify_success. Tokens DictIA (brand-navy,
brand-bg, grad-bg, shadow-cta), French copy, WCAG patterns (label for,
focus-visible:outline-2, role=alert, aria-required, text-brand-navy/70 minimum,
NBSP français pour Loi 25 / 24 heures / 1 heure / 8 caractères).
Translate inline French flash messages in src/api/auth.py for /verify-email,
/resend-verification, /forgot-password, /reset-password. Anti-enumeration fix:
forgot_password no longer flashes the cooldown remaining (would leak account
existence) — silently skips resend, generic flash unchanged. Cooldown logic
in src/services/email.py UNCHANGED (60s — verified by test).
config/env.email.example: defaults to Resend SMTP at the top + adds Resend
to the provider examples list (preserves Gmail/SendGrid/Mailgun/SES/M365).
Tests: tests/test_email_service_dictia.py — 12 tests covering DictIA branding,
French copy, display-name fallback, anti-enumeration parity (forgot_password
returns identical message for known/unknown emails), 60s cooldown, SMTP-not-
configured returns False (no exception), check_email.html extends marketing/base
(no var(--text-primary) leaks). Includes Windows manual driver
(_run_email_service_dictia_windows.py) since pytest cannot collect on Windows
native (fcntl POSIX-only).
NO new dependency added (no resend SDK — SMTP via existing _send_email).
NO new route added or removed.
NO src/auth_extended/ created.
NO change to itsdangerous-based token logic.
templates/auth/**/*.html already in tailwind.config.js content array (B-2.2).
Verified locally on Windows manual driver: 12/12 PASS B-2.3, 9/9 PASS regression
on B-2.2 signup, 9/9 PASS regression on B-2.1 ConsentLog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C-1: Add templates/register.html (and templates/auth/**) to tailwind.config.js
content array so utility classes used by the signup template don't get purged
on next build. Rebuilt static/css/marketing.css; verified text-brand-navy/90
and min-h-[calc(100vh-62px)] are now compiled.
I-1: Replace flash() calls for missing required consents with WTForms
field-level errors (form.consent_cgu.errors.append / form.consent_confidentialite
.errors.append). Errors render inline next to each consent checkbox via
{% if form.consent_cgu.errors %}<p role="alert">…</p>{% endif %}. Prevents
session-backed flash messages from leaking across unrelated navigations.
I-2: Wrap user creation + flush in IntegrityError retry loop (max 5 attempts);
import IntegrityError from sqlalchemy.exc. Absorbs the inherent race between
_generate_unique_username's lookup and the subsequent flush under concurrent
signups. Added docstring note to _generate_unique_username explaining the
wrapper.
I-3: Move db.create_all() inside the try/finally in
test_signup_route_csrf_enforced so WTF_CSRF_ENABLED is restored even if
table creation fails.
I-4: Pin test_signup_rejects_duplicate_email assertion to status_code == 200
(WTForms validate_email raises ValidationError → form fails validation →
fall-through to default 200 render_template).
I-5: Add id="password-help" to the password help paragraph and
aria-describedby="password-help" to the password input so screen readers
announce the password requirements when the field is focused.
I-6: Bump flash banner text colors from -700/-800 to -900 variants
(text-amber-900, text-blue-900, text-red-900, text-green-900) for safer
WCAG 2.2 AA contrast against the -50 backgrounds. Same bump applied to the
new consent and password inline error renders.
- Loi 25 article number badges (Art. 3.3, 3.5, 14): change from
`grad-bg text-white text-xs font-black` to `bg-brand-navy text-white
text-xs font-black`. White-on-grad-bg failed AA on the cyan/green
portion of the gradient (~2:1 contrast); solid navy gives ~16:1.
- Update routes.py module docstring to past tense (A-2.8b is now done).
- Add regression assertion ensuring badges use solid navy, not grad-bg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract 3 pricing tiers to templates/marketing/_partials/_pricing_tiers.html
Single source of truth — landing.html and tarifs.html now {% include %} it.
Prevents price drift (LPC art. 219 risk).
- Sync bento card #2 description across landing + fonctionnalites
(was diverged: 'embeddings' vs 'embeddings vocaux'). Add maintenance
reminder comments in both files.
- Fix OQLF NBSP on '~2 semaines' matrix cells in /tarifs deep-dive table.
- Fix mixed UTF-8/entity 'québécois' -> 'québécois' in tech
specs (consistent with rest of file).
- Calibrate H2 size on /tarifs FAQ to match landing (clamp 2.75rem cap).
- Repair 2 pre-existing test bugs from earlier A-2.x commits:
* 'violent la Loi 25' -> accept both NBSP and plain forms (commit 7c6c6fd
added the NBSP after the test was written)
* 'résilie' -> 'résilie' (Jinja outputs raw UTF-8, not entities)
- Update src/marketing/routes.py module docstring to reflect 2/4 done.
- Drop role="region" from FAQ panels (had no accessible name — axe-core
violation; disclosure pattern with button + aria-controls + aria-expanded
is sufficient per WAI-APG accordion guidance)
- Add focus-visible:outline-2 outline-brand-b1 outline-offset-2 to FAQ
buttons (WCAG 2.2 AA 2.4.7 Focus Not Obscured + 2.4.11 Focus Appearance —
Safari default focus indicator is unreliable)
- Sweep pre-existing text-white/50 on Hero social proof microcopy → /70
(branch-wide WCAG floor; recurring landmine flagged at A-2.7a review)
- Strengthen test_faq_jsonld_schema_present to json.loads() the extracted
block and validate the FAQPage schema shape (regression guard for future
content edits with unescaped backslashes/quotes)
- Reword comparatif row 1 to make Teams ✗ for non-Loi-25-compliant US transfer
(was ⚠ — too soft; territoriality is binary)
- Reword row 2 to positive: 'Souveraineté hors Cloud Act US' (was inverted —
✓ used for bad outcomes, breaking visual scan convention)
- Reword row 4 criterion to match deliverable: 'Diarisation jusqu'à 8 locuteurs'
(was '8+', mismatched DictIA's '✓ Jusqu'à 8' cell)
- Reword row 5 to 'Coût mensuel par utilisateur' (was 'utilisateur/mois' —
awkward French + missing NBSP per OQLF)
- Hedge SOC 2 Type II claim about OVH Beauharnois — third-party certification
scope-dependent; reword to 'conformité documentée selon les services
(ISO 27001, SOC 2 selon le périmètre)' to avoid LPC art. 219 risk
- Replace 🇨🇦 regional indicator pair with 🍁 maple leaf — renders reliably
on Windows + Firefox Linux without color-emoji fonts
- Update existing test for renamed criteria keywords
- Add regression test for ✓-means-good convention + SOC 2 hedge guard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ROI payback now returns raw months; template branches to 'moins d'un mois'
for sub-month paybacks and 'Payable dès la première année' when savings≤0
(was rounding up to 'Payback : 1 mois' for ~95% of slider combos)
- Cap sliders: users 1..25 (was 50), hours 0.5..4 (was 8) to keep displayed
savings in a defensible band (~8.8 M$/yr max instead of 35 M$)
- pricing_card href uses cta_url.rstrip('/') to avoid double-slash if caller
passes a trailing slash (preempts A-2.8 / B-2.7 regression)
- aria-live polite + aria-atomic on the savings paragraph so screen readers
announce slider updates
- Cleaner JS module pattern: single window.roiCalculator = function() {...}
- Tests updated for payback ternary; new tests for slider caps, aria-live,
and double-slash guard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Pipe macro title/description through | safe to render NBSP/& correctly
(autoescape was producing literal '95 %+' and 'Q&R' text on screen)
- Replace dynamic col-span-{{ span }} with static lookup table so Tailwind
scanner generates the utilities for A-2.7+ reuse
- Replace inline border style with border-white/[0.045] utility (codebase consistency)
- Add explicit Q&R assertion + autoescape regression guard test
- Problème section (light bg-brand-bg, white cards): 3 risk cards
(Cloud Act, Loi 25 biométrie art. 60.1 LPRPSP, sanctions disciplinaires
9 ordres pros). H2 grad-text accent on 'violent la Loi 25' — defensible
legal claim citing CAI + LPRPSP statutes by name. text-brand-navy/70 for
all body text (WCAG AA compliant, no /40 or /50 regression).
- Solution section (bg-brand-navy with single subtle green orb): 3 pillars
(100% local, Conforme Loi 25, Précision FR-CA). H2 grad-text accent on
'par design'. Pillars cite specific tech (WhisperX Large-v3, Mistral 7B,
pyannote, OVH Beauharnois, AGPL v3) — all factually verifiable.
- French typography: NBSP via + |safe before %, $, and within
'Loi 25' to prevent line break separation (OQLF rule).
- 4 new tests verify both sections, 3 cards each, key tech mentions,
and WCAG-safe opacity policy.
- Critical (C1): align 9-ordre list with dictia.ca canonical (Barreau, CNQ,
CPA, ChAD, OACIQ, CMQ, OIIQ, OPQ, OEQ). Drop ambiguous OPPQ; replace M/P
short monograms with disambiguated 3-5 char abbreviations (BAR, CNQ, CPA,
ChAD, OACIQ, CMQ, OIIQ, OPQ, OEQ). Tooltips show full disambiguating names.
- Critical (C2): raise text-brand-navy/40 -> /70 on footnote (2.69:1 -> 9:1
contrast, passes WCAG AAA) and text-[10px] navy/50 -> text-xs navy/70 on
monogram captions (12px minimum + AA contrast). Critical for legal
disclosure legibility.
- Critical (C3): drop unverifiable '50 heures' specific number from
methodology footnote — replaced with 'methodologie disponible sur demande'
(defensible without committing to numbers we can't verify).
- Important (I1): use before %/$ in KPI numbers per OQLF French
typography rules + |safe filter to render entities.
- Important (I2): replace fragile substring-strip test with explicit
forbidden-phrase list (RECONNU PAR, ENDOSSÉ PAR, etc.). Update ordre
list test + footnote test to match new wording.
- Section AFTER hero, white bg with brand-border y-borders
- 9 monogram placeholders (gradient circles with initials, NOT official
logos to avoid licensing issues + false-endorsement exposure)
hover from opacity-50 to opacity-100 for subtle interaction
- Eyebrow phrasing 'MAPPÉ AUX 9 ORDRES PROFESSIONNELS' (factual scope,
not 'CERTIFIÉ PAR' which would be a false-endorsement claim under
LPC art. 219 / Competition Act s. 52)
- 4 KPIs with grad-text numbers: ~5 min/heure, 95%+ FR-CA, 0$ par user,
100% local — each with a 1-line context line and a small subtext
- Methodology footnote: 'Précision mesurée sur 50 heures d'audio
interne, détails sur demande' — defensible disclosure for the 95%
claim (LPC art. 219 hygiene)
- 4 new tests verify ordres list, factual phrasing, KPIs, footnote
- Add @media (prefers-reduced-motion: reduce) override to disable animations
for vestibular-sensitive users (covers hero + future scroll/infinite anims)
- Replace 5-star icon + '27 cabinets' claim with shield + 'Conçu avec 9
ordres professionnels' + 'Pré-inscription ouverte' (LPC art. 219 +
Competition Act s. 52 compliance — pre-launch claims must be factual)
- Convert H1/H2/H3 letter-spacing from absolute px to em-relative (-3px →
-0.028em on H1) so tracking scales correctly with clamp font-size on mobile
- Update test_hero_has_social_proof_microcopy to assert new copy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Button macro: variants/sizes use .get() with primary/md fallback (no KeyError),
add as_button=True parameter for form submit CTAs (forms in B-2.2 signup,
B-2.7 checkout)
- Header: drop hidden sm:inline-block on Connexion link so login is always
visible from mobile (avoids UX dead-end before A-2.7 hamburger lands)
- Add placeholder static/images/favicon.svg (brand gradient + 'D')
- Add placeholder 1x1 PNG static/images/og/og-default.png (replaced in A-3.3
with real 1200x630 branded OG image)