feat(auth): B-2.3 emails FR + DictIA branding (SMTP Resend)

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>
This commit is contained in:
Allison
2026-04-27 23:02:20 -04:00
parent 3b324ad0b9
commit 37639a7d09
10 changed files with 666 additions and 520 deletions

View File

@@ -14,25 +14,25 @@ ENABLE_EMAIL_VERIFICATION=false
REQUIRE_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) # SMTP server hostname (required for email functionality)
# Examples: smtp.gmail.com, smtp.sendgrid.net, smtp.mailgun.org # DictIA default: Resend SMTP relay (https://resend.com)
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.resend.com
# SMTP server port # SMTP server port
# Common ports: 587 (TLS/STARTTLS), 465 (SSL), 25 (unencrypted) # Common ports: 587 (TLS/STARTTLS), 465 (SSL), 2587 (alt-TLS)
# Default: 587 # Default: 587
SMTP_PORT=587 SMTP_PORT=587
# SMTP authentication username (usually your email address) # SMTP authentication username
SMTP_USERNAME=your-email@gmail.com # For Resend: literal "resend"
SMTP_USERNAME=resend
# SMTP authentication password # SMTP authentication password
# For Gmail: Use an App Password (not your regular password) # For Resend: an API key from https://resend.com/api-keys (starts with "re_")
# https://support.google.com/accounts/answer/185833 SMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxxxxxxxxxxx
SMTP_PASSWORD=your-app-password
# Use TLS/STARTTLS encryption (recommended for port 587) # Use TLS/STARTTLS encryption (recommended for port 587)
# Default: true # Default: true
@@ -44,17 +44,27 @@ SMTP_USE_TLS=true
SMTP_USE_SSL=false SMTP_USE_SSL=false
# Email address that appears in the "From" field # Email address that appears in the "From" field
# Should be a valid email address, ideally matching your domain # Domain MUST be verified in your Resend dashboard (DKIM + SPF + DMARC)
SMTP_FROM_ADDRESS=noreply@yourdomain.com # Canonical for DictIA: noreply@dictia.ca
SMTP_FROM_ADDRESS=noreply@dictia.ca
# Display name that appears alongside the from address # Display name that appears alongside the from address
# Default: Speakr # Default: DictIA
SMTP_FROM_NAME=Speakr SMTP_FROM_NAME=DictIA
############################################################################### ###############################################################################
# Provider-Specific Examples # 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 --- # --- Gmail ---
# SMTP_HOST=smtp.gmail.com # SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587 # SMTP_PORT=587
@@ -104,6 +114,6 @@ SMTP_FROM_NAME=Speakr
# Security Recommendations: # Security Recommendations:
# - Always use TLS or SSL encryption # - Always use TLS or SSL encryption
# - Use app-specific passwords when available (Gmail, etc.) # - Use app-specific passwords or API keys when available (Resend, Gmail, etc.)
# - Consider using a dedicated email service (SendGrid, Mailgun, SES) # - For DictIA: prefer Resend (DKIM/SPF/DMARC handled, Loi 25-friendly logs in EU)
# - Set a strong SECRET_KEY in your Flask configuration # - Set a strong SECRET_KEY in your Flask configuration

View File

@@ -546,16 +546,16 @@ def verify_email(token):
user_id = verify_email_token(token) user_id = verify_email_token(token)
if user_id is None: 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')) return redirect(url_for('auth.login'))
user = db.session.get(User, user_id) user = db.session.get(User, user_id)
if not user: if not user:
flash('User not found.', 'danger') flash('Utilisateur introuvable.', 'danger')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
if user.email_verified: 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')) return redirect(url_for('auth.login'))
# Verify the email # Verify the email
@@ -563,7 +563,7 @@ def verify_email(token):
user.email_verification_token = None # Clear the token user.email_verification_token = None # Clear the token
db.session.commit() 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']) @auth_bp.route('/resend-verification', methods=['POST'])
@@ -571,48 +571,48 @@ def verify_email(token):
def resend_verification(): def resend_verification():
"""Resend verification email.""" """Resend verification email."""
if not is_email_verification_enabled(): 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')) return redirect(url_for('auth.login'))
if not is_smtp_configured(): 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')) return redirect(url_for('auth.login'))
# Get email from session (set during failed login) or form # Get email from session (set during failed login) or form
email = session.get('unverified_email') or request.form.get('email') email = session.get('unverified_email') or request.form.get('email')
if not email: if not email:
flash('Email address is required.', 'danger') flash("L'adresse courriel est requise.", 'danger')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
if not user: if not user:
# Don't reveal if user exists # Don't reveal if user exists (anti-enumeration — Loi 25 / OWASP A01)
flash('If an account exists with this email, a verification link has been sent.', 'info') flash('Si un compte existe pour ce courriel, un lien de vérification a été envoyé.', 'info')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
if user.email_verified: 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')) return redirect(url_for('auth.login'))
# Check cooldown # Check cooldown
can_resend, remaining = can_resend_verification(user) can_resend, remaining = can_resend_verification(user)
if not can_resend: 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', return render_template('auth/check_email.html',
title='Check Your Email', title='Vérification requise',
email=email, email=email,
action='verification_required', action='verification_required',
show_resend=True) show_resend=True)
if send_verification_email(user): 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: 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', return render_template('auth/check_email.html',
title='Check Your Email', title='Confirmez votre courriel',
email=email, email=email,
action='verification', action='verification',
show_resend=True) show_resend=True)
@@ -628,15 +628,15 @@ def forgot_password():
return redirect(url_for('recordings.index')) return redirect(url_for('recordings.index'))
if not is_smtp_configured(): 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')) return redirect(url_for('auth.login'))
if request.method == 'POST': if request.method == 'POST':
email = request.form.get('email') email = request.form.get('email')
if not email: if not email:
flash('Email address is required.', 'danger') flash("L'adresse courriel est requise.", 'danger')
return render_template('auth/forgot_password.html', title='Forgot Password') return render_template('auth/forgot_password.html', title='Mot de passe oublié')
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
@@ -644,20 +644,19 @@ def forgot_password():
if user: if user:
# Check if user has a password (not SSO-only) # Check if user has a password (not SSO-only)
if user.password: if user.password:
# Check cooldown # Check cooldown — silently skip resend; we still show the
can_resend, remaining = can_resend_password_reset(user) # same generic flash to avoid leaking that an account exists.
if not can_resend: can_resend, _remaining = can_resend_password_reset(user)
flash(f'Please wait {remaining} seconds before requesting another reset email.', 'warning') if can_resend:
else:
send_password_reset_email(user) 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', return render_template('auth/check_email.html',
title='Check Your Email', title='Vérifiez votre courriel',
email=email, email=email,
action='password_reset') 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/<token>', methods=['GET', 'POST']) @auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
@@ -670,12 +669,12 @@ def reset_password(token):
user_id = verify_reset_token(token) user_id = verify_reset_token(token)
if user_id is None: 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')) return redirect(url_for('auth.forgot_password'))
user = db.session.get(User, user_id) user = db.session.get(User, user_id)
if not user: if not user:
flash('User not found.', 'danger') flash('Utilisateur introuvable.', 'danger')
return redirect(url_for('auth.forgot_password')) return redirect(url_for('auth.forgot_password'))
if request.method == 'POST': if request.method == 'POST':
@@ -683,19 +682,19 @@ def reset_password(token):
confirm_password = request.form.get('confirm_password') confirm_password = request.form.get('confirm_password')
if not password or not confirm_password: if not password or not confirm_password:
flash('Both password fields are required.', 'danger') flash('Les deux champs de mot de passe sont requis.', '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)
if password != confirm_password: if password != confirm_password:
flash('Passwords do not match.', 'danger') flash('Les mots de passe ne correspondent pas.', '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)
# Validate password # Validate password
try: try:
password_check(None, type('obj', (object,), {'data': password})) password_check(None, type('obj', (object,), {'data': password}))
except ValidationError as e: except ValidationError as e:
flash(str(e), 'danger') 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 # Update password
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8') hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
@@ -710,10 +709,10 @@ def reset_password(token):
db.session.commit() db.session.commit()
audit_password_reset(user.id) 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 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']) @auth_bp.route('/account', methods=['GET', 'POST'])

View File

@@ -24,7 +24,12 @@ PASSWORD_RESET_EXPIRY = 1 * 60 * 60 # 1 hour in seconds
def get_email_config(): 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 { return {
'enabled': os.environ.get('ENABLE_EMAIL_VERIFICATION', 'false').lower() == 'true', 'enabled': os.environ.get('ENABLE_EMAIL_VERIFICATION', 'false').lower() == 'true',
'required': os.environ.get('REQUIRE_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_tls': os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true',
'smtp_use_ssl': os.environ.get('SMTP_USE_SSL', 'false').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_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]: 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) 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: 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: except RuntimeError:
# Outside of request context, use a placeholder # Outside of request context, use a placeholder
logo_url = "" 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""" html_body = f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="fr-CA">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head> </head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #1f2937; margin: 0; padding: 0; background-color: #e8eaed;"> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #060d1a; margin: 0; padding: 0; background-color: #f7f9fc;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #e8eaed;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f7f9fc;">
<tr> <tr>
<td style="padding: 40px 20px;"> <td style="padding: 40px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; margin: 0 auto;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; margin: 0 auto;">
<!-- Header --> <!-- Header -->
<tr> <tr>
<td style="background-color: #2563eb; padding: 32px 40px; border-radius: 12px 12px 0 0;"> <td style="{header_bg} padding: 32px 40px; border-radius: 12px 12px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td> <td>
@@ -198,10 +217,10 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
<table role="presentation" cellspacing="0" cellpadding="0" border="0"> <table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr> <tr>
<td style="vertical-align: middle; padding-right: 12px;"> <td style="vertical-align: middle; padding-right: 12px;">
<img src="{logo_url}" alt="Speakr" width="44" height="44" style="display: block; border-radius: 8px;"> <img src="{logo_url}" alt="DictIA" width="44" height="44" style="display: block; border-radius: 8px;">
</td> </td>
<td style="vertical-align: middle;"> <td style="vertical-align: middle;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">Speakr</h1> <h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">DictIA</h1>
</td> </td>
</tr> </tr>
</table> </table>
@@ -209,7 +228,7 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
</tr> </tr>
<tr> <tr>
<td style="padding-top: 8px;"> <td style="padding-top: 8px;">
<p style="color: rgba(255,255,255,0.85); margin: 0; font-size: 14px;">AI-Powered Audio Transcription</p> <p style="color: rgba(255,255,255,0.92); margin: 0; font-size: 14px;">Transcription IA conforme Loi&nbsp;25</p>
</td> </td>
</tr> </tr>
</table> </table>
@@ -218,22 +237,22 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
<!-- Content --> <!-- Content -->
<tr> <tr>
<td style="background-color: #ffffff; padding: 40px; border-left: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;"> <td style="background-color: #ffffff; padding: 40px; border-left: 1px solid #e6ebf2; border-right: 1px solid #e6ebf2;">
{content_html} {content_html}
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="background-color: #f8f9fa; padding: 24px 40px; border-radius: 0 0 12px 12px; border: 1px solid #e5e7eb; border-top: none;"> <td style="background-color: #f7f9fc; padding: 24px 40px; border-radius: 0 0 12px 12px; border: 1px solid #e6ebf2; border-top: none;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="text-align: center;"> <td style="text-align: center;">
<p style="color: #6b7280; font-size: 12px; margin: 0 0 8px 0;"> <p style="color: #4b5563; font-size: 12px; margin: 0 0 8px 0;">
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 <a href="mailto:info@dictia.ca" style="color: #0062ff; text-decoration: none;">info@dictia.ca</a>.
</p> </p>
<p style="color: #9ca3af; font-size: 11px; margin: 0;"> <p style="color: #6b7280; font-size: 11px; margin: 0;">
&copy; {datetime.utcnow().year} Speakr &middot; AI-Powered Audio Transcription &copy; {datetime.utcnow().year} DictIA &mdash; Transcription IA conforme Loi&nbsp;25
</p> </p>
</td> </td>
</tr> </tr>
@@ -255,8 +274,8 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
{content_text} {content_text}
--- ---
This email was sent by Speakr - AI-Powered Audio Transcription. Ce courriel vous est envoyé par DictIA — Transcription IA conforme Loi 25.
If you have questions, please contact your administrator. Pour toute question, contactez info@dictia.ca.
""" """
return html_body, text_body return html_body, text_body
@@ -290,41 +309,44 @@ def send_verification_email(user) -> bool:
# Build verification URL # Build verification URL
verify_url = url_for('auth.verify_email', token=token, _external=True) 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""" content_html = f"""
<h2 style="color: #1f2937; margin: 0 0 24px 0; font-size: 24px; font-weight: 600;">Verify Your Email Address</h2> <h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Vérifiez votre adresse courriel</h2>
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Hi {user.username},</p> <p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name},</p>
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;"> <p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
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.
</p> </p>
<div style="text-align: center; margin: 32px 0;"> <div style="text-align: center; margin: 32px 0;">
<a href="{verify_url}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Verify Email Address</a> <a href="{verify_url}" style="display: inline-block; background-color: #0062ff; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Vérifier mon courriel</a>
</div> </div>
<p style="color: #6b7280; font-size: 14px; margin: 24px 0 8px 0;">Or copy and paste this link into your browser:</p> <p style="color: #4b5563; font-size: 14px; margin: 24px 0 8px 0;">Ou copiez-collez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; color: #2563eb; font-size: 14px; margin: 0; padding: 12px; background-color: #f3f4f6; border-radius: 6px;">{verify_url}</p> <p style="word-break: break-all; color: #0062ff; font-size: 14px; margin: 0; padding: 12px; background-color: #f7f9fc; border-radius: 6px;">{verify_url}</p>
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;"> <div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
<p style="color: #9ca3af; font-size: 13px; margin: 0;"> <p style="color: #4b5563; font-size: 13px; margin: 0;">
<strong>This link will expire in 24 hours.</strong><br> <strong>Ce lien expire dans 24&nbsp;heures.</strong><br>
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.
</p> </p>
</div> </div>
""" """
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) html_body, text_body = _get_email_template(content_html, content_text, subject)
return _send_email(user.email, subject, html_body, text_body) return _send_email(user.email, subject, html_body, text_body)
@@ -354,50 +376,44 @@ def send_password_reset_email(user) -> bool:
# Build reset URL # Build reset URL
reset_url = url_for('auth.reset_password', token=token, _external=True) 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""" content_html = f"""
<h2 style="color: #1f2937; margin: 0 0 24px 0; font-size: 24px; font-weight: 600;">Reset Your Password</h2> <h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Réinitialiser votre mot de passe</h2>
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Hi {user.username},</p> <p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name},</p>
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;"> <p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
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.
</p> </p>
<div style="text-align: center; margin: 32px 0;"> <div style="text-align: center; margin: 32px 0;">
<a href="{reset_url}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Reset Password</a> <a href="{reset_url}" style="display: inline-block; background-color: #0062ff; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Réinitialiser mon mot de passe</a>
</div> </div>
<p style="color: #6b7280; font-size: 14px; margin: 24px 0 8px 0;">Or copy and paste this link into your browser:</p> <p style="color: #4b5563; font-size: 14px; margin: 24px 0 8px 0;">Ou copiez-collez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; color: #2563eb; font-size: 14px; margin: 0; padding: 12px; background-color: #f3f4f6; border-radius: 6px;">{reset_url}</p> <p style="word-break: break-all; color: #0062ff; font-size: 14px; margin: 0; padding: 12px; background-color: #f7f9fc; border-radius: 6px;">{reset_url}</p>
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;"> <div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <p style="color: #4b5563; font-size: 13px; margin: 0;">
<tr> <strong>Ce lien expire dans 1&nbsp;heure.</strong><br>
<td style="width: 24px; vertical-align: top; padding-right: 12px;"> Si vous n'avez pas demandé de réinitialisation, ignorez ce courriel — votre mot de passe reste inchangé.
<span style="font-size: 18px;">⚠️</span>
</td>
<td>
<p style="color: #9ca3af; font-size: 13px; margin: 0;">
<strong style="color: #6b7280;">This link will expire in 1 hour.</strong><br>
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
</p> </p>
</td>
</tr>
</table>
</div> </div>
""" """
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} {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) html_body, text_body = _get_email_template(content_html, content_text, subject)
return _send_email(user.email, subject, html_body, text_body) return _send_email(user.email, subject, html_body, text_body)

View File

@@ -747,9 +747,6 @@
.h-16 { .h-16 {
height: calc(var(--spacing) * 16); height: calc(var(--spacing) * 16);
} }
.h-20 {
height: calc(var(--spacing) * 20);
}
.h-24 { .h-24 {
height: calc(var(--spacing) * 24); height: calc(var(--spacing) * 24);
} }
@@ -1668,9 +1665,6 @@
.bg-\[var\(--bg-warn-light\)\] { .bg-\[var\(--bg-warn-light\)\] {
background-color: 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\)\] { .bg-\[var\(--border-accent\)\] {
background-color: var(--border-accent); background-color: var(--border-accent);
} }

View File

@@ -1,127 +1,61 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% 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 %}
<script> {% block content %}
function applyTheme() { <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="check-email-title">
if (!document.documentElement) return; <div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta text-center">
const savedMode = localStorage.getItem('darkMode'); <div class="mx-auto mb-6 w-16 h-16 rounded-full grad-bg flex items-center justify-center text-white text-2xl" aria-hidden="true">&#x2709;</div>
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedMode === 'true' || (savedMode === null && prefersDark)) { <h1 id="check-email-title" class="text-2xl font-black text-brand-navy mb-2">
document.documentElement.classList.add('dark'); {% if action == 'password_reset' %}Vérifiez votre courriel
} else { {% elif action == 'verification_required' %}Vérification requise
document.documentElement.classList.remove('dark'); {% else %}Confirmez votre courriel{% endif %}
}
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1> </h1>
</header>
<main class="flex-grow flex items-center justify-center"> <p class="text-sm text-brand-navy/70 mb-6">
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> {% if action == 'password_reset' %}
{{ "Si un compte DictIA existe pour <strong>" ~ email ~ "</strong>, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1&nbsp;heure." | safe }}
{% elif action == 'verification_required' %}
{{ "Vérifiez votre boîte de réception à <strong>" ~ email ~ "</strong>. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous." | safe }}
{% else %}
{{ "Nous avons envoyé un lien de vérification à <strong>" ~ email ~ "</strong>. Cliquez dessus pour activer votre compte. Le lien expire dans 24&nbsp;heures." | safe }}
{% endif %}
</p>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}"> <div role="alert" class="mb-3 p-3 rounded-lg text-sm
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }} {{ message }}
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<div class="text-center"> {% if show_resend and action != 'password_reset' %}
<div class="mb-6"> <form method="POST" action="{{ url_for('auth.resend_verification') }}" class="mb-4">
<div class="w-20 h-20 mx-auto bg-[var(--bg-info-light)] rounded-full flex items-center justify-center">
<i class="fas fa-envelope text-[var(--text-info-strong)] text-3xl"></i>
</div>
</div>
{% if action == 'verification' %}
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Check Your Email</h2>
<p class="text-[var(--text-secondary)] mb-2">We've sent a verification link to:</p>
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
<p class="text-[var(--text-muted)] text-sm mb-6">
Click the link in the email to verify your account. The link will expire in 24 hours.
</p>
{% elif action == 'verification_required' %}
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Email Verification Required</h2>
<p class="text-[var(--text-secondary)] mb-2">Please verify your email address:</p>
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
<p class="text-[var(--text-muted)] text-sm mb-6">
Check your inbox for a verification email. If you haven't received it, you can request a new one.
</p>
{% elif action == 'password_reset' %}
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Check Your Email</h2>
<p class="text-[var(--text-secondary)] mb-2">If an account exists with this email:</p>
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
<p class="text-[var(--text-muted)] text-sm mb-6">
We've sent a password reset link. The link will expire in 1 hour.
</p>
{% endif %}
{% if show_resend and (action == 'verification' or action == 'verification_required') %}
<div class="mb-6">
<form method="POST" action="{{ url_for('auth.resend_verification') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="email" value="{{ email }}"> <input type="hidden" name="email" value="{{ email }}">
<button type="submit" class="text-[var(--text-accent)] hover:underline text-sm"> <button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<i class="fas fa-redo mr-1"></i> Resend verification email Renvoyer le lien de vérification
</button> </button>
</form> </form>
</div>
{% endif %} {% endif %}
<div class="pt-4 border-t border-[var(--border-secondary)]"> <p class="text-xs text-brand-navy/70 mt-4">
<a href="{{ url_for('auth.login') }}" class="text-[var(--text-accent)] hover:underline"> Vous ne recevez rien&nbsp;? Vérifiez vos pourriels (spam) ou
<i class="fas fa-arrow-left mr-1"></i> Back to Login <a href="mailto:info@dictia.ca" class="grad-text font-semibold">contactez le support</a>.
</a> </p>
</div>
</div>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]"> <p class="mt-6 text-sm">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div> <a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">&larr; Retour à la connexion</a>
</footer> </p>
</div> </div>
</section>
<script> {% endblock %}
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -1,105 +1,46 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% 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 %}
<script> {% block content %}
function applyTheme() { <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="forgot-title">
if (!document.documentElement) return; <div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
const savedMode = localStorage.getItem('darkMode'); <h1 id="forgot-title" class="text-3xl font-black text-brand-navy mb-2">Mot de passe oublié</h1>
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; <p class="text-sm text-brand-navy/70 mb-6">{{ "Entrez votre adresse courriel. Si un compte existe, nous vous enverrons un lien sécurisé pour réinitialiser votre mot de passe (valide 1&nbsp;heure)." | safe }}</p>
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center">
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Forgot Password</h2>
<p class="text-[var(--text-muted)] text-sm text-center mb-6">
Enter your email address and we'll send you a link to reset your password.
</p>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}"> <div role="alert" class="mb-3 p-3 rounded-lg text-sm
{{ message }} {% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message | safe }}
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="POST" action="{{ url_for('auth.forgot_password') }}"> <form method="POST" action="{{ url_for('auth.forgot_password') }}" class="space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-6"> <div>
<label for="email" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Email Address</label> <label for="email" class="block text-sm font-medium text-brand-navy mb-1">Courriel <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" required <input type="email" id="email" name="email" autocomplete="email" required aria-required="true"
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]" class="w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="Enter your email address"> placeholder="vous@cabinet.qc.ca">
</div> </div>
<div class="flex flex-col space-y-4"> <button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]"> Recevoir un lien de réinitialisation
<i class="fas fa-paper-plane mr-2"></i> Send Reset Link
</button> </button>
<div class="text-center text-sm text-[var(--text-muted)]">
<span>Remember your password?</span>
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">Back to Login</a>
</div>
</div>
</form> </form>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]"> <p class="text-center text-sm text-brand-navy/70 mt-6">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div> <a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">&larr; Retour à la connexion</a>
</footer> </p>
</div> </div>
</section>
<script> {% endblock %}
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -1,114 +1,54 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% 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 %}
<script> {% block content %}
function applyTheme() { <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="reset-title">
if (!document.documentElement) return; <div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
const savedMode = localStorage.getItem('darkMode'); <h1 id="reset-title" class="text-3xl font-black text-brand-navy mb-2">Nouveau mot de passe</h1>
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; <p class="text-sm text-brand-navy/70 mb-6">Choisissez un mot de passe robuste pour sécuriser votre compte DictIA.</p>
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center">
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Reset Password</h2>
<p class="text-[var(--text-muted)] text-sm text-center mb-6">
Enter your new password below.
</p>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}"> <div role="alert" class="mb-3 p-3 rounded-lg text-sm
{{ message }} {% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message | safe }}
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="POST" action="{{ url_for('auth.reset_password', token=token) }}"> <form method="POST" action="{{ url_for('auth.reset_password', token=token) }}" class="space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4"> <div>
<label for="password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">New Password</label> <label for="password" class="block text-sm font-medium text-brand-navy mb-1">Nouveau mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="password" id="password" name="password" required <input type="password" id="password" name="password" autocomplete="new-password" minlength="8" required aria-required="true" aria-describedby="password-help"
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]" class="w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="Enter your new password"> placeholder="••••••••">
<p class="text-xs text-[var(--text-muted)] mt-1">Password must be at least 8 characters long.</p> <p id="password-help" class="text-xs text-brand-navy/70 mt-1">{{ "8&nbsp;caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial." | safe }}</p>
</div> </div>
<div class="mb-6"> <div>
<label for="confirm_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Confirm Password</label> <label for="confirm_password" class="block text-sm font-medium text-brand-navy mb-1">Confirmer le mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
<input type="password" id="confirm_password" name="confirm_password" required <input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" minlength="8" required aria-required="true"
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]" class="w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
placeholder="Confirm your new password"> placeholder="••••••••">
</div> </div>
<div class="flex flex-col space-y-4"> <button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]"> Définir mon nouveau mot de passe
<i class="fas fa-key mr-2"></i> Reset Password
</button> </button>
<div class="text-center text-sm text-[var(--text-muted)]">
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">
<i class="fas fa-arrow-left mr-1"></i> Back to Login
</a>
</div>
</div>
</form> </form>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]"> <p class="text-center text-sm text-brand-navy/70 mt-6">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div> <a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">&larr; Retour à la connexion</a>
</footer> </p>
</div> </div>
</section>
<script> {% endblock %}
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -1,85 +1,27 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
{% 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 %}
<script> {% block content %}
function applyTheme() { <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="verify-success-title">
if (!document.documentElement) return; <div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta text-center">
const savedMode = localStorage.getItem('darkMode'); <div class="mx-auto mb-6 w-16 h-16 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-3xl font-black" aria-hidden="true">&check;</div>
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
if (savedScheme !== 'blue') {
document.documentElement.classList.add(themePrefix + savedScheme);
}
}
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center"> <h1 id="verify-success-title" class="text-2xl font-black text-brand-navy mb-2">Votre courriel a été vérifié</h1>
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> <p class="text-sm text-brand-navy/70 mb-6">
<div class="text-center"> Vous pouvez maintenant vous connecter à votre compte DictIA et commencer à transcrire en toute conformité Loi&nbsp;25.
<div class="mb-6">
<div class="w-20 h-20 mx-auto bg-[var(--bg-success-light)] rounded-full flex items-center justify-center">
<i class="fas fa-check text-[var(--text-success-strong)] text-3xl"></i>
</div>
</div>
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Email Verified!</h2>
<p class="text-[var(--text-secondary)] mb-6">
Your email address has been successfully verified. You can now log in to your account.
</p> </p>
<a href="{{ url_for('auth.login') }}" class="inline-block w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200"> <a href="{{ url_for('auth.login') }}"
<i class="fas fa-sign-in-alt mr-2"></i> Continue to Login class="inline-block w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
Se connecter
</a> </a>
</div>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]"> <p class="text-xs text-brand-navy/70 mt-6">
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div> Une question&nbsp;? Écrivez-nous à
</footer> <a href="mailto:info@dictia.ca" class="grad-text font-semibold">info@dictia.ca</a>.
</p>
</div> </div>
</section>
<script> {% endblock %}
document.addEventListener('DOMContentLoaded', function() {
if (window.AppLoader) {
AppLoader.waitForReady();
}
});
</script>
</body>
</html>

View File

@@ -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)

View File

@@ -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='<p>hello</p>',
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()