From 37639a7d09b2e30165db3312c8dc84c6d4e046e0 Mon Sep 17 00:00:00 2001 From: Allison Date: Mon, 27 Apr 2026 23:02:20 -0400 Subject: [PATCH] feat(auth): B-2.3 emails FR + DictIA branding (SMTP Resend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- config/env.email.example | 40 +-- src/api/auth.py | 69 +++-- src/services/email.py | 138 +++++----- static/css/marketing.css | 6 - templates/auth/check_email.html | 172 ++++-------- templates/auth/forgot_password.html | 137 +++------- templates/auth/reset_password.html | 152 ++++------- templates/auth/verify_success.html | 102 ++----- tests/_run_email_service_dictia_windows.py | 78 ++++++ tests/test_email_service_dictia.py | 292 +++++++++++++++++++++ 10 files changed, 666 insertions(+), 520 deletions(-) create mode 100644 tests/_run_email_service_dictia_windows.py create mode 100644 tests/test_email_service_dictia.py diff --git a/config/env.email.example b/config/env.email.example index 80590f7..b8b90f5 100644 --- a/config/env.email.example +++ b/config/env.email.example @@ -14,25 +14,25 @@ ENABLE_EMAIL_VERIFICATION=false REQUIRE_EMAIL_VERIFICATION=false ############################################################################### -# SMTP Configuration +# SMTP Configuration (Resend recommended for DictIA — Loi 25 compliant via DKIM/SPF/DMARC) ############################################################################### # SMTP server hostname (required for email functionality) -# Examples: smtp.gmail.com, smtp.sendgrid.net, smtp.mailgun.org -SMTP_HOST=smtp.gmail.com +# DictIA default: Resend SMTP relay (https://resend.com) +SMTP_HOST=smtp.resend.com # SMTP server port -# Common ports: 587 (TLS/STARTTLS), 465 (SSL), 25 (unencrypted) +# Common ports: 587 (TLS/STARTTLS), 465 (SSL), 2587 (alt-TLS) # Default: 587 SMTP_PORT=587 -# SMTP authentication username (usually your email address) -SMTP_USERNAME=your-email@gmail.com +# SMTP authentication username +# For Resend: literal "resend" +SMTP_USERNAME=resend # SMTP authentication password -# For Gmail: Use an App Password (not your regular password) -# https://support.google.com/accounts/answer/185833 -SMTP_PASSWORD=your-app-password +# For Resend: an API key from https://resend.com/api-keys (starts with "re_") +SMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxxxxxxxxxxx # Use TLS/STARTTLS encryption (recommended for port 587) # Default: true @@ -44,17 +44,27 @@ SMTP_USE_TLS=true SMTP_USE_SSL=false # Email address that appears in the "From" field -# Should be a valid email address, ideally matching your domain -SMTP_FROM_ADDRESS=noreply@yourdomain.com +# Domain MUST be verified in your Resend dashboard (DKIM + SPF + DMARC) +# Canonical for DictIA: noreply@dictia.ca +SMTP_FROM_ADDRESS=noreply@dictia.ca # Display name that appears alongside the from address -# Default: Speakr -SMTP_FROM_NAME=Speakr +# Default: DictIA +SMTP_FROM_NAME=DictIA ############################################################################### # Provider-Specific Examples ############################################################################### +# --- Resend (recommended for DictIA — TLS, DKIM/SPF/DMARC, Cloudflare-friendly) --- +# SMTP_HOST=smtp.resend.com +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_USERNAME=resend +# SMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxxxxxxxxxxx # Get from https://resend.com/api-keys +# SMTP_FROM_ADDRESS=noreply@dictia.ca # Domain MUST be verified in Resend dashboard +# SMTP_FROM_NAME=DictIA + # --- Gmail --- # SMTP_HOST=smtp.gmail.com # SMTP_PORT=587 @@ -104,6 +114,6 @@ SMTP_FROM_NAME=Speakr # Security Recommendations: # - Always use TLS or SSL encryption -# - Use app-specific passwords when available (Gmail, etc.) -# - Consider using a dedicated email service (SendGrid, Mailgun, SES) +# - Use app-specific passwords or API keys when available (Resend, Gmail, etc.) +# - For DictIA: prefer Resend (DKIM/SPF/DMARC handled, Loi 25-friendly logs in EU) # - Set a strong SECRET_KEY in your Flask configuration diff --git a/src/api/auth.py b/src/api/auth.py index 1d9efef..d570bcb 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -546,16 +546,16 @@ def verify_email(token): user_id = verify_email_token(token) if user_id is None: - flash('The verification link is invalid or has expired.', 'danger') + flash('Le lien de vérification est invalide ou expiré.', 'danger') return redirect(url_for('auth.login')) user = db.session.get(User, user_id) if not user: - flash('User not found.', 'danger') + flash('Utilisateur introuvable.', 'danger') return redirect(url_for('auth.login')) if user.email_verified: - flash('Your email has already been verified.', 'info') + flash('Votre courriel a déjà été vérifié.', 'info') return redirect(url_for('auth.login')) # Verify the email @@ -563,7 +563,7 @@ def verify_email(token): user.email_verification_token = None # Clear the token db.session.commit() - return render_template('auth/verify_success.html', title='Email Verified') + return render_template('auth/verify_success.html', title='Courriel vérifié') @auth_bp.route('/resend-verification', methods=['POST']) @@ -571,48 +571,48 @@ def verify_email(token): def resend_verification(): """Resend verification email.""" if not is_email_verification_enabled(): - flash('Email verification is not enabled.', 'danger') + flash("La vérification de courriel n'est pas activée.", 'danger') return redirect(url_for('auth.login')) if not is_smtp_configured(): - flash('Email service is not configured.', 'danger') + flash("Le service de courriel n'est pas configuré.", 'danger') return redirect(url_for('auth.login')) # Get email from session (set during failed login) or form email = session.get('unverified_email') or request.form.get('email') if not email: - flash('Email address is required.', 'danger') + flash("L'adresse courriel est requise.", 'danger') return redirect(url_for('auth.login')) user = User.query.filter_by(email=email).first() if not user: - # Don't reveal if user exists - flash('If an account exists with this email, a verification link has been sent.', 'info') + # Don't reveal if user exists (anti-enumeration — Loi 25 / OWASP A01) + flash('Si un compte existe pour ce courriel, un lien de vérification a été envoyé.', 'info') return redirect(url_for('auth.login')) if user.email_verified: - flash('Your email has already been verified.', 'info') + flash('Votre courriel a déjà été vérifié.', 'info') return redirect(url_for('auth.login')) # Check cooldown can_resend, remaining = can_resend_verification(user) if not can_resend: - flash(f'Please wait {remaining} seconds before requesting another verification email.', 'warning') + flash(f'Veuillez attendre {remaining} secondes avant de demander un nouveau courriel.', 'warning') return render_template('auth/check_email.html', - title='Check Your Email', + title='Vérification requise', email=email, action='verification_required', show_resend=True) if send_verification_email(user): - flash('A new verification email has been sent.', 'success') + flash('Un nouveau courriel de vérification a été envoyé.', 'success') else: - flash('Failed to send verification email. Please try again later.', 'danger') + flash("L'envoi du courriel de vérification a échoué. Réessayez plus tard.", 'danger') return render_template('auth/check_email.html', - title='Check Your Email', + title='Confirmez votre courriel', email=email, action='verification', show_resend=True) @@ -628,15 +628,15 @@ def forgot_password(): return redirect(url_for('recordings.index')) if not is_smtp_configured(): - flash('Password reset is not available. Please contact the administrator.', 'warning') + flash("La réinitialisation de mot de passe n'est pas disponible. Contactez l'administrateur.", 'warning') return redirect(url_for('auth.login')) if request.method == 'POST': email = request.form.get('email') if not email: - flash('Email address is required.', 'danger') - return render_template('auth/forgot_password.html', title='Forgot Password') + flash("L'adresse courriel est requise.", 'danger') + return render_template('auth/forgot_password.html', title='Mot de passe oublié') user = User.query.filter_by(email=email).first() @@ -644,20 +644,19 @@ def forgot_password(): if user: # Check if user has a password (not SSO-only) if user.password: - # Check cooldown - can_resend, remaining = can_resend_password_reset(user) - if not can_resend: - flash(f'Please wait {remaining} seconds before requesting another reset email.', 'warning') - else: + # Check cooldown — silently skip resend; we still show the + # same generic flash to avoid leaking that an account exists. + can_resend, _remaining = can_resend_password_reset(user) + if can_resend: send_password_reset_email(user) - flash('If an account exists with this email, a password reset link has been sent.', 'info') + flash('Si un compte existe pour ce courriel, un lien de réinitialisation a été envoyé.', 'info') return render_template('auth/check_email.html', - title='Check Your Email', + title='Vérifiez votre courriel', email=email, action='password_reset') - return render_template('auth/forgot_password.html', title='Forgot Password') + return render_template('auth/forgot_password.html', title='Mot de passe oublié') @auth_bp.route('/reset-password/', methods=['GET', 'POST']) @@ -670,12 +669,12 @@ def reset_password(token): user_id = verify_reset_token(token) if user_id is None: - flash('The password reset link is invalid or has expired.', 'danger') + flash('Le lien de réinitialisation est invalide ou expiré.', 'danger') return redirect(url_for('auth.forgot_password')) user = db.session.get(User, user_id) if not user: - flash('User not found.', 'danger') + flash('Utilisateur introuvable.', 'danger') return redirect(url_for('auth.forgot_password')) if request.method == 'POST': @@ -683,19 +682,19 @@ def reset_password(token): confirm_password = request.form.get('confirm_password') if not password or not confirm_password: - flash('Both password fields are required.', 'danger') - return render_template('auth/reset_password.html', title='Reset Password', token=token) + flash('Les deux champs de mot de passe sont requis.', 'danger') + return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token) if password != confirm_password: - flash('Passwords do not match.', 'danger') - return render_template('auth/reset_password.html', title='Reset Password', token=token) + flash('Les mots de passe ne correspondent pas.', 'danger') + return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token) # Validate password try: password_check(None, type('obj', (object,), {'data': password})) except ValidationError as e: flash(str(e), 'danger') - return render_template('auth/reset_password.html', title='Reset Password', token=token) + return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token) # Update password hashed_password = bcrypt.generate_password_hash(password).decode('utf-8') @@ -710,10 +709,10 @@ def reset_password(token): db.session.commit() audit_password_reset(user.id) - flash('Your password has been reset. You can now log in with your new password.', 'success') + flash('Votre mot de passe a été réinitialisé. Vous pouvez vous connecter.', 'success') return redirect(url_for('auth.login')) - return render_template('auth/reset_password.html', title='Reset Password', token=token) + return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token) @auth_bp.route('/account', methods=['GET', 'POST']) diff --git a/src/services/email.py b/src/services/email.py index a5f84d0..ef521cb 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -24,7 +24,12 @@ PASSWORD_RESET_EXPIRY = 1 * 60 * 60 # 1 hour in seconds def get_email_config(): - """Get email configuration from environment variables.""" + """Get email configuration from environment variables. + + Defaults are tuned for DictIA + Resend SMTP. Operators MUST set + ``SMTP_FROM_ADDRESS`` to a domain verified in their Resend dashboard + (e.g. ``noreply@dictia.ca``). + """ return { 'enabled': os.environ.get('ENABLE_EMAIL_VERIFICATION', 'false').lower() == 'true', 'required': os.environ.get('REQUIRE_EMAIL_VERIFICATION', 'false').lower() == 'true', @@ -35,7 +40,7 @@ def get_email_config(): 'smtp_use_tls': os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true', 'smtp_use_ssl': os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true', 'from_address': os.environ.get('SMTP_FROM_ADDRESS', 'noreply@yourdomain.com'), - 'from_name': os.environ.get('SMTP_FROM_NAME', 'Speakr'), + 'from_name': os.environ.get('SMTP_FROM_NAME', 'DictIA'), } @@ -165,32 +170,46 @@ def _send_email(to_email: str, subject: str, html_body: str, text_body: str = No def _get_email_template(content_html: str, content_text: str, subject: str) -> tuple[str, str]: """ - Wrap content in the Speakr email template. + Wrap content in the DictIA branded email template. + + Header uses the DictIA brand gradient (118deg, #0062ff → #00bdd8 → #00c896) + with a #0062ff fallback for clients that don't render gradients in inline + styles. Footer mentions ``info@dictia.ca`` (canonical contact) and the + Loi 25 tagline. Returns (html_body, text_body) """ - # Get the base URL for the logo + # Get the base URL for the logo. We prefer the dedicated DictIA logo + # (logo-dictia.png) over the legacy PWA icon. try: - logo_url = url_for('static', filename='img/icon-192x192.png', _external=True) + logo_url = url_for('static', filename='img/logo-dictia.png', _external=True) except RuntimeError: # Outside of request context, use a placeholder logo_url = "" + # Header: solid #0062ff fallback + linear-gradient overlay (best-effort + # for the email clients that support inline-style gradients — Apple Mail, + # iOS Mail, Gmail web). + header_bg = ( + "background-color: #0062ff; " + "background-image: linear-gradient(118deg, #0062ff 0%, #00bdd8 52%, #00c896 100%);" + ) + html_body = f""" - + - - + +
- - -
+
@@ -198,10 +217,10 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
- Speakr + DictIA -

Speakr

+

DictIA

@@ -209,7 +228,7 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
-

AI-Powered Audio Transcription

+

Transcription IA conforme Loi 25

@@ -218,22 +237,22 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
+ {content_html}
+ @@ -255,8 +274,8 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t {content_text} --- -This email was sent by Speakr - AI-Powered Audio Transcription. -If you have questions, please contact your administrator. +Ce courriel vous est envoyé par DictIA — Transcription IA conforme Loi 25. +Pour toute question, contactez info@dictia.ca. """ return html_body, text_body @@ -290,41 +309,44 @@ def send_verification_email(user) -> bool: # Build verification URL verify_url = url_for('auth.verify_email', token=token, _external=True) - subject = "Verify your email address - Speakr" + # Display name preferred over username; fallback when name is None/empty. + display_name = (getattr(user, 'name', None) or user.username).strip() + + subject = "Vérifiez votre courriel — DictIA" content_html = f""" -

Verify Your Email Address

+

Vérifiez votre adresse courriel

-

Hi {user.username},

+

Bonjour {display_name},

- Welcome to Speakr! To complete your registration and start transcribing your audio recordings, please verify your email address. + Bienvenue chez DictIA. Pour terminer votre inscription et commencer à transcrire, vérifiez votre adresse courriel.

-

Or copy and paste this link into your browser:

-

{verify_url}

+

Ou copiez-collez ce lien dans votre navigateur :

+

{verify_url}

-
-

- This link will expire in 24 hours.
- If you didn't create an account on Speakr, you can safely ignore this email. +

+

+ Ce lien expire dans 24 heures.
+ Si vous n'avez pas créé de compte DictIA, ignorez ce courriel.

""" - content_text = f"""Hi {user.username}, + content_text = f"""Bonjour {display_name}, -Welcome to Speakr! To complete your registration and start transcribing your audio recordings, please verify your email address. +Bienvenue chez DictIA. Pour terminer votre inscription et commencer à transcrire, vérifiez votre adresse courriel. -Click here to verify: {verify_url} +Cliquez ici pour vérifier : {verify_url} -This link will expire in 24 hours. +Ce lien expire dans 24 heures. -If you didn't create an account on Speakr, you can safely ignore this email.""" +Si vous n'avez pas créé de compte DictIA, ignorez ce courriel.""" html_body, text_body = _get_email_template(content_html, content_text, subject) return _send_email(user.email, subject, html_body, text_body) @@ -354,50 +376,44 @@ def send_password_reset_email(user) -> bool: # Build reset URL reset_url = url_for('auth.reset_password', token=token, _external=True) - subject = "Reset your password - Speakr" + # Display name preferred over username; fallback when name is None/empty. + display_name = (getattr(user, 'name', None) or user.username).strip() + + subject = "Réinitialiser votre mot de passe — DictIA" content_html = f""" -

Reset Your Password

+

Réinitialiser votre mot de passe

-

Hi {user.username},

+

Bonjour {display_name},

- We received a request to reset your Speakr account password. Click the button below to create a new password. + Nous avons reçu une demande de réinitialisation pour votre compte DictIA. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe.

-

Or copy and paste this link into your browser:

-

{reset_url}

+

Ou copiez-collez ce lien dans votre navigateur :

+

{reset_url}

-
-
-

- This email was sent by Speakr. If you have questions, please contact your administrator. +

+ Ce courriel vous est envoyé par DictIA. Pour toute question, contactez info@dictia.ca.

-

- © {datetime.utcnow().year} Speakr · AI-Powered Audio Transcription +

+ © {datetime.utcnow().year} DictIA — Transcription IA conforme Loi 25

- - - - -
- ⚠️ - -

- This link will expire in 1 hour.
- If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. -

-
+
+

+ Ce lien expire dans 1 heure.
+ Si vous n'avez pas demandé de réinitialisation, ignorez ce courriel — votre mot de passe reste inchangé. +

""" - content_text = f"""Hi {user.username}, + content_text = f"""Bonjour {display_name}, -We received a request to reset your Speakr account password. Click the link below to create a new password: +Nous avons reçu une demande de réinitialisation pour votre compte DictIA. Cliquez sur le lien ci-dessous pour créer un nouveau mot de passe : {reset_url} -This link will expire in 1 hour. +Ce lien expire dans 1 heure. -If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.""" +Si vous n'avez pas demandé de réinitialisation, ignorez ce courriel — votre mot de passe reste inchangé.""" html_body, text_body = _get_email_template(content_html, content_text, subject) return _send_email(user.email, subject, html_body, text_body) diff --git a/static/css/marketing.css b/static/css/marketing.css index 1c5c6d8..f0faa4a 100644 --- a/static/css/marketing.css +++ b/static/css/marketing.css @@ -747,9 +747,6 @@ .h-16 { height: calc(var(--spacing) * 16); } - .h-20 { - height: calc(var(--spacing) * 20); - } .h-24 { height: calc(var(--spacing) * 24); } @@ -1668,9 +1665,6 @@ .bg-\[var\(--bg-warn-light\)\] { background-color: var(--bg-warn-light); } - .bg-\[var\(--bg-warning-light\)\] { - background-color: var(--bg-warning-light); - } .bg-\[var\(--border-accent\)\] { background-color: var(--border-accent); } diff --git a/templates/auth/check_email.html b/templates/auth/check_email.html index 8962962..841fcc4 100644 --- a/templates/auth/check_email.html +++ b/templates/auth/check_email.html @@ -1,127 +1,61 @@ - - - - - - - {{ title }} - DictIA - - - - +{% extends 'marketing/base.html' %} - {% include 'includes/loading_overlay.html' %} +{% block title %}{% if action == 'password_reset' %}Vérifiez votre courriel — DictIA{% else %}Confirmez votre courriel — DictIA{% endif %}{% endblock %} +{% block description %}Un courriel vous a été envoyé. Suivez le lien pour activer votre compte DictIA.{% endblock %} - - - -
-
-

- - DictIA - DictIA - -

-
+{% block content %} +
+
+ -
-
- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} +

+ {% if action == 'password_reset' %}Vérifiez votre courriel + {% elif action == 'verification_required' %}Vérification requise + {% else %}Confirmez votre courriel{% endif %} +

-
-
-
- -
-
+

+ {% if action == 'password_reset' %} + {{ "Si un compte DictIA existe pour " ~ email ~ ", vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1 heure." | safe }} + {% elif action == 'verification_required' %} + {{ "Vérifiez votre boîte de réception à " ~ email ~ ". Si vous ne recevez rien, demandez un nouveau courriel ci-dessous." | safe }} + {% else %} + {{ "Nous avons envoyé un lien de vérification à " ~ email ~ ". Cliquez dessus pour activer votre compte. Le lien expire dans 24 heures." | safe }} + {% endif %} +

- {% if action == 'verification' %} -

Check Your Email

-

We've sent a verification link to:

-

{{ email }}

-

- Click the link in the email to verify your account. The link will expire in 24 hours. -

- {% elif action == 'verification_required' %} -

Email Verification Required

-

Please verify your email address:

-

{{ email }}

-

- Check your inbox for a verification email. If you haven't received it, you can request a new one. -

- {% elif action == 'password_reset' %} -

Check Your Email

-

If an account exists with this email:

-

{{ email }}

-

- We've sent a password reset link. The link will expire in 1 hour. -

- {% endif %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} - {% if show_resend and (action == 'verification' or action == 'verification_required') %} -
-
- - - -
-
- {% endif %} + {% if show_resend and action != 'password_reset' %} +
+ + + +
+ {% endif %} - -
-
-
+

+ Vous ne recevez rien ? Vérifiez vos pourriels (spam) ou + contactez le support. +

- -
- - - - +

+ ← Retour à la connexion +

+
+ +{% endblock %} diff --git a/templates/auth/forgot_password.html b/templates/auth/forgot_password.html index 80941fb..520ebb0 100644 --- a/templates/auth/forgot_password.html +++ b/templates/auth/forgot_password.html @@ -1,105 +1,46 @@ - - - - - - - {{ title }} - DictIA - - - - +{% extends 'marketing/base.html' %} - {% include 'includes/loading_overlay.html' %} +{% block title %}Mot de passe oublié — DictIA{% endblock %} +{% block description %}Recevez un lien sécurisé pour réinitialiser le mot de passe de votre compte DictIA.{% endblock %} - - - -
-
-

- - DictIA - DictIA - -

-
+{% block content %} +
+
+

Mot de passe oublié

+

{{ "Entrez votre adresse courriel. Si un compte existe, nous vous enverrons un lien sécurisé pour réinitialiser votre mot de passe (valide 1 heure)." | safe }}

-
-
-

Forgot Password

-

- Enter your email address and we'll send you a link to reset your password. -

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} +
+ - - +
+ + +
-
- - -
+ +
-
- - -
- Remember your password? - Back to Login -
-
- -
-
- - -
- - - - +

+ ← Retour à la connexion +

+
+ +{% endblock %} diff --git a/templates/auth/reset_password.html b/templates/auth/reset_password.html index c3dcdf7..2f1c3d5 100644 --- a/templates/auth/reset_password.html +++ b/templates/auth/reset_password.html @@ -1,114 +1,54 @@ - - - - - - - {{ title }} - DictIA - - - - +{% extends 'marketing/base.html' %} - {% include 'includes/loading_overlay.html' %} +{% block title %}Nouveau mot de passe — DictIA{% endblock %} +{% block description %}Définissez un nouveau mot de passe pour votre compte DictIA. Lien sécurisé valide 1 heure.{% endblock %} - - - -
-
-

- - DictIA - DictIA - -

-
+{% block content %} +
+
+

Nouveau mot de passe

+

Choisissez un mot de passe robuste pour sécuriser votre compte DictIA.

-
-
-

Reset Password

-

- Enter your new password below. -

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} +
+ - - +
+ + +

{{ "8 caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial." | safe }}

+
-
- - -

Password must be at least 8 characters long.

-
+
+ + +
-
- - -
+ +
-
- - - -
- -
-
- - -
- - - - +

+ ← Retour à la connexion +

+
+ +{% endblock %} diff --git a/templates/auth/verify_success.html b/templates/auth/verify_success.html index 86084b0..dfc56dc 100644 --- a/templates/auth/verify_success.html +++ b/templates/auth/verify_success.html @@ -1,85 +1,27 @@ - - - - - - - {{ title }} - DictIA - - - - +{% extends 'marketing/base.html' %} - {% include 'includes/loading_overlay.html' %} +{% block title %}Courriel vérifié — DictIA{% endblock %} +{% block description %}Votre courriel a été vérifié. Vous pouvez maintenant vous connecter à votre compte DictIA.{% endblock %} - - - -
-
-

- - DictIA - DictIA - -

-
+{% block content %} +
+
+ -
-
-
-
-
- -
-
+

Votre courriel a été vérifié

+

+ Vous pouvez maintenant vous connecter à votre compte DictIA et commencer à transcrire en toute conformité Loi 25. +

-

Email Verified!

-

- Your email address has been successfully verified. You can now log in to your account. -

+ + Se connecter + - - Continue to Login - -
-
-
- - -
- - - - +

+ Une question ? Écrivez-nous à + info@dictia.ca. +

+
+ +{% endblock %} diff --git a/tests/_run_email_service_dictia_windows.py b/tests/_run_email_service_dictia_windows.py new file mode 100644 index 0000000..09870e4 --- /dev/null +++ b/tests/_run_email_service_dictia_windows.py @@ -0,0 +1,78 @@ +"""Windows manual driver for tests/test_email_service_dictia.py. + +src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it +before src.app gets imported, then run each test_* function and report. + +Run from the repo root: + py -3 tests/_run_email_service_dictia_windows.py + +This script is local-dev only (not picked up by pytest collection). +""" +import os +import sys +import types +import traceback + +# 1) Stub fcntl BEFORE any import of src.* happens. +if 'fcntl' not in sys.modules: + fcntl_stub = types.ModuleType('fcntl') + fcntl_stub.LOCK_EX = 2 + fcntl_stub.LOCK_NB = 4 + fcntl_stub.LOCK_UN = 8 + fcntl_stub.LOCK_SH = 1 + fcntl_stub.flock = lambda *_args, **_kw: None + fcntl_stub.fcntl = lambda *_args, **_kw: 0 + sys.modules['fcntl'] = fcntl_stub + +# 2) Make repo root importable +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(HERE) +sys.path.insert(0, REPO) + +# 3) Set test config +os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:') +os.environ.setdefault('SECRET_KEY', 'test-secret-key') +os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false') +# Avoid sys.exit(1) in src/config/app_config.py legacy validation. +os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub') +os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub') +# Disable rate limits for forgot_password endpoint test. +os.environ.setdefault('RATELIMIT_ENABLED', 'false') +# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows. +try: + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + sys.stderr.reconfigure(encoding='utf-8', errors='replace') +except Exception: + pass + +# 4) Import the test module and run every test_* function it defines +import importlib.util # noqa: E402 +spec = importlib.util.spec_from_file_location( + 'test_email_service_dictia', + os.path.join(HERE, 'test_email_service_dictia.py'), +) +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) + +tests = [(name, fn) for name, fn in vars(mod).items() + if name.startswith('test_') and callable(fn)] + +passed = 0 +failed = [] +for name, fn in tests: + try: + fn() + print(f' PASS {name}') + passed += 1 + except Exception as e: # noqa: BLE001 + print(f' FAIL {name}: {type(e).__name__}: {e}') + failed.append((name, traceback.format_exc())) + +total = len(tests) +print() +print(f'Result: {passed}/{total} passed, {len(failed)} failed') +if failed: + print('\n--- Failures ---\n') + for name, tb in failed: + print(f'### {name}\n{tb}\n') +sys.exit(0 if not failed else 1) diff --git a/tests/test_email_service_dictia.py b/tests/test_email_service_dictia.py new file mode 100644 index 0000000..1764f53 --- /dev/null +++ b/tests/test_email_service_dictia.py @@ -0,0 +1,292 @@ +"""Tests for B-2.3 — DictIA-branded French transactional emails (verification + reset). + +Covers: + - _get_email_template uses DictIA branding (no "Speakr" leaks). + - send_verification_email subject/body in French + DictIA. + - send_password_reset_email subject/body in French + DictIA. + - User display name (user.name) used in greetings, fallback to username. + - Anti-enumeration: /forgot-password gives the same flash for known/unknown emails. + - Cooldowns are enforced (60s) for resend-verification. + - SMTP_FROM_NAME defaults to "DictIA" when env var unset. + - send_verification_email returns False (no exception) when SMTP misconfigured. + - check_email.html refondu — extends marketing/base.html (DictIA brand tokens, no + legacy `var(--text-primary)` styles). + +Note: pytest cannot collect this file on Windows native because src/init_db.py +imports `fcntl` (POSIX-only). Tests run in CI / Docker. A manual driver may be +provided alongside this file for Windows verification. +""" +import os +import sys +from datetime import datetime, timedelta +from unittest.mock import patch + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:') +os.environ.setdefault('SECRET_KEY', 'test-secret-key') + +from src.app import app, db # noqa: E402 +from src.models.user import User # noqa: E402 + + +# --- Helpers ---------------------------------------------------------------- + +def _set_smtp_env(): + os.environ['ENABLE_EMAIL_VERIFICATION'] = 'true' + os.environ['SMTP_HOST'] = 'smtp.test' + os.environ['SMTP_USERNAME'] = 'u' + os.environ['SMTP_PASSWORD'] = 'p' + + +def _clear_smtp_env(): + for k in ('ENABLE_EMAIL_VERIFICATION', 'REQUIRE_EMAIL_VERIFICATION', + 'SMTP_HOST', 'SMTP_USERNAME', 'SMTP_PASSWORD', + 'SMTP_FROM_NAME', 'SMTP_FROM_ADDRESS'): + os.environ.pop(k, None) + + +def _make_user(username='jane', email='jane@x.qc.ca', name='Jane Bouchard'): + user = User(username=username, email=email, password='x' * 60, + name=name, email_verified=False) + db.session.add(user) + db.session.commit() + return user + + +# --- Tests ------------------------------------------------------------------ + +def test_email_template_uses_dictia_branding(): + """_get_email_template wraps content in DictIA-branded HTML scaffold (no Speakr).""" + with app.app_context(): + from src.services.email import _get_email_template + html, text = _get_email_template( + content_html='

hello

', + content_text='hello', + subject='Test', + ) + assert 'DictIA' in html, 'HTML must contain DictIA brand' + assert 'DictIA' in text, 'Plain text must contain DictIA brand' + assert 'Speakr' not in html, 'No "Speakr" string must remain in template' + assert 'Speakr' not in text, 'No "Speakr" string must remain in plain text' + # French footer copy + canonical contact email + assert 'info@dictia.ca' in html + assert 'Loi' in html and '25' in html, 'Tagline must mention Loi 25' + + +def test_email_template_header_uses_brand_gradient_not_speakr_blue(): + """Header bg must use DictIA brand color #0062ff (or gradient), not the legacy + Speakr #2563eb.""" + with app.app_context(): + from src.services.email import _get_email_template + html, _ = _get_email_template('x', 'x', 'Test') + assert '#2563eb' not in html, 'Legacy Speakr header color must be removed' + assert '#0062ff' in html, 'DictIA brand blue must be present' + + +def test_verification_email_subject_is_french_with_dictia(): + """Subject = 'Vérifiez votre courriel — DictIA'.""" + with app.test_request_context('/'): + _set_smtp_env() + db.create_all() + try: + user = _make_user() + with patch('src.services.email._send_email', return_value=True) as mock_send: + from src.services.email import send_verification_email + send_verification_email(user) + args, _ = mock_send.call_args + _to, subject, _html, _text = args + assert subject == 'Vérifiez votre courriel — DictIA' + finally: + db.session.rollback() + db.drop_all() + _clear_smtp_env() + + +def test_verification_email_body_uses_user_name_when_set(): + """Greeting uses user.name (display name) when populated.""" + with app.test_request_context('/'): + _set_smtp_env() + db.create_all() + try: + user = _make_user(username='jane123', email='jane@x.qc.ca', + name='Jane Bouchard') + with patch('src.services.email._send_email', return_value=True) as mock_send: + from src.services.email import send_verification_email + send_verification_email(user) + args, _ = mock_send.call_args + _to, _subject, html, text = args + assert 'Bonjour Jane Bouchard' in html + assert 'Bonjour Jane Bouchard' in text + assert 'Bonjour jane123' not in html + # French body copy + assert 'Vérifier mon courriel' in html + assert 'Bienvenue chez DictIA' in html or "Bienvenue chez DictIA" in text + finally: + db.session.rollback() + db.drop_all() + _clear_smtp_env() + + +def test_verification_email_body_falls_back_to_username(): + """When user.name is None, greeting uses user.username.""" + with app.test_request_context('/'): + _set_smtp_env() + db.create_all() + try: + user = _make_user(username='bob42', email='bob@x.qc.ca', name=None) + with patch('src.services.email._send_email', return_value=True) as mock_send: + from src.services.email import send_verification_email + send_verification_email(user) + args, _ = mock_send.call_args + _to, _subject, html, _text = args + assert 'Bonjour bob42' in html + finally: + db.session.rollback() + db.drop_all() + _clear_smtp_env() + + +def test_password_reset_subject_french(): + """Subject = 'Réinitialiser votre mot de passe — DictIA'.""" + with app.test_request_context('/'): + _set_smtp_env() + db.create_all() + try: + user = _make_user(username='carol', email='carol@x.qc.ca', + name='Carol Tremblay') + with patch('src.services.email._send_email', return_value=True) as mock_send: + from src.services.email import send_password_reset_email + send_password_reset_email(user) + args, _ = mock_send.call_args + _to, subject, html, _text = args + assert subject == 'Réinitialiser votre mot de passe — DictIA' + assert 'Bonjour Carol Tremblay' in html + assert 'Réinitialiser mon mot de passe' in html + assert 'Speakr' not in html + finally: + db.session.rollback() + db.drop_all() + _clear_smtp_env() + + +def test_send_verification_returns_false_when_smtp_not_configured(): + """No exception, just False — keeps registration robust.""" + with app.app_context(): + _clear_smtp_env() + # Verification enabled but SMTP missing + os.environ['ENABLE_EMAIL_VERIFICATION'] = 'true' + db.create_all() + try: + user = _make_user() + from src.services.email import send_verification_email + assert send_verification_email(user) is False + finally: + db.session.rollback() + db.drop_all() + _clear_smtp_env() + + +def test_smtp_from_name_defaults_to_dictia(): + """When SMTP_FROM_NAME is unset, get_email_config() returns 'DictIA'.""" + _clear_smtp_env() + from src.services.email import get_email_config + cfg = get_email_config() + assert cfg['from_name'] == 'DictIA', ( + 'Default SMTP_FROM_NAME must be "DictIA", not "Speakr"' + ) + + +def test_forgot_password_returns_generic_message_for_unknown_email(): + """Anti-enumeration: unknown email gets the same generic message.""" + with app.app_context(): + _set_smtp_env() + app.config['WTF_CSRF_ENABLED'] = False + db.create_all() + try: + client = app.test_client() + # No user exists with this email + with patch('src.services.email._send_email', return_value=True): + resp = client.post('/forgot-password', + data={'email': 'nobody@nope.qc.ca'}) + # Page should render the generic message in body + body = resp.data.decode('utf-8') + assert 'Si un compte' in body or 'lien de réinitialisation' in body + finally: + db.session.rollback() + db.drop_all() + _clear_smtp_env() + + +def test_forgot_password_returns_same_message_for_known_email(): + """Anti-enumeration: known email gets the SAME generic message.""" + with app.app_context(): + _set_smtp_env() + app.config['WTF_CSRF_ENABLED'] = False + db.create_all() + try: + user = _make_user(username='dora', email='dora@x.qc.ca') + client = app.test_client() + with patch('src.services.email._send_email', return_value=True): + resp = client.post('/forgot-password', + data={'email': user.email}) + body = resp.data.decode('utf-8') + assert 'Si un compte' in body or 'lien de réinitialisation' in body + finally: + db.session.rollback() + db.drop_all() + _clear_smtp_env() + + +def test_check_email_template_extends_marketing_base(): + """check_email.html uses DictIA marketing layout, no legacy Vue styles.""" + with app.test_request_context('/'): + _set_smtp_env() + app.config['WTF_CSRF_ENABLED'] = False + db.create_all() + try: + from flask import render_template + html = render_template( + 'auth/check_email.html', + title='Vérifiez votre courriel', + email='alice@x.qc.ca', + action='verification', + show_resend=True, + ) + # New marketing layout markers + assert 'marketing.css' in html or 'grad-text' in html or 'brand-navy' in html + # Legacy Vue/Tailwind v3 design tokens MUST be gone + assert 'var(--text-primary)' not in html + assert 'var(--bg-secondary)' not in html + # French + brand + assert 'DictIA' in html + assert 'alice@x.qc.ca' in html + finally: + db.session.rollback() + db.drop_all() + _clear_smtp_env() + + +def test_resend_verification_rate_limited_per_user(): + """can_resend_verification returns (False, remaining) within the 60s cooldown.""" + with app.app_context(): + _set_smtp_env() + db.create_all() + try: + user = _make_user(username='eric', email='eric@x.qc.ca') + from src.services.email import can_resend_verification + # Simulate recent send + user.email_verification_sent_at = datetime.utcnow() + db.session.commit() + can, remaining = can_resend_verification(user) + assert can is False + assert remaining is not None and remaining > 0 + # Simulate older send (>60s) — should now allow + user.email_verification_sent_at = datetime.utcnow() - timedelta(seconds=120) + db.session.commit() + can, remaining = can_resend_verification(user) + assert can is True + assert remaining is None + finally: + db.session.rollback() + db.drop_all() + _clear_smtp_env()