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:
@@ -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 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;">
|
||||
© {datetime.utcnow().year} Speakr · AI-Powered Audio Transcription
|
||||
<p style="color: #6b7280; font-size: 11px; margin: 0;">
|
||||
© {datetime.utcnow().year} DictIA — Transcription IA conforme Loi 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 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 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)
|
||||
|
||||
Reference in New Issue
Block a user