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

@@ -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/<token>', 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'])

View File

@@ -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"""
<!DOCTYPE html>
<html>
<html lang="fr-CA">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</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;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="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: #f7f9fc;">
<tr>
<td style="padding: 40px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; margin: 0 auto;">
<!-- Header -->
<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%">
<tr>
<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">
<tr>
<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 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>
</tr>
</table>
@@ -209,7 +228,7 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
</tr>
<tr>
<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>
</tr>
</table>
@@ -218,22 +237,22 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
<!-- Content -->
<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}
</td>
</tr>
<!-- Footer -->
<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%">
<tr>
<td style="text-align: center;">
<p style="color: #6b7280; font-size: 12px; margin: 0 0 8px 0;">
This email was sent by Speakr. If you have questions, please contact your administrator.
<p style="color: #4b5563; font-size: 12px; margin: 0 0 8px 0;">
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 style="color: #9ca3af; font-size: 11px; margin: 0;">
&copy; {datetime.utcnow().year} Speakr &middot; AI-Powered Audio Transcription
<p style="color: #6b7280; font-size: 11px; margin: 0;">
&copy; {datetime.utcnow().year} DictIA &mdash; Transcription IA conforme Loi&nbsp;25
</p>
</td>
</tr>
@@ -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"""
<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;">
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>
<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>
<p style="color: #6b7280; font-size: 14px; margin: 24px 0 8px 0;">Or copy and paste this link into your browser:</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="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: #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;">
<p style="color: #9ca3af; font-size: 13px; margin: 0;">
<strong>This link will expire in 24 hours.</strong><br>
If you didn't create an account on Speakr, you can safely ignore this email.
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
<p style="color: #4b5563; font-size: 13px; margin: 0;">
<strong>Ce lien expire dans 24&nbsp;heures.</strong><br>
Si vous n'avez pas créé de compte DictIA, ignorez ce courriel.
</p>
</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)
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"""
<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;">
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>
<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>
<p style="color: #6b7280; font-size: 14px; margin: 24px 0 8px 0;">Or copy and paste this link into your browser:</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="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: #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;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 24px; vertical-align: top; padding-right: 12px;">
<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>
</td>
</tr>
</table>
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
<p style="color: #4b5563; font-size: 13px; margin: 0;">
<strong>Ce lien expire dans 1&nbsp;heure.</strong><br>
Si vous n'avez pas demandé de réinitialisation, ignorez ce courriel — votre mot de passe reste inchangé.
</p>
</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}
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)