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

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