feat(email): add provider abstraction and HTML templates

Add pluggable email provider system supporting SMTP, Mailgun, and SendGrid
with factory pattern for runtime provider selection. Includes branded HTML
email templates for verification, password reset, MFA notifications, and
organization invites.

Also rebrands all email content from Gatehouse to Secuird, adds email
provider configuration options, and fixes duplicate log handlers in
development mode.
This commit is contained in:
2026-04-04 16:55:00 +10:30
parent d90a06437e
commit 41bbdb4bef
17 changed files with 1068 additions and 76 deletions
+498
View File
@@ -0,0 +1,498 @@
"""HTML Email Templates for Secuird.
This module provides beautifully designed HTML email templates with
Secuird branding, responsive design, and consistent styling.
"""
import os
from typing import Optional
from flask import current_app
PRIMARY_COLOR = "#36b9a6"
PRIMARY_DARK = "#2d9a89"
TEXT_COLOR = "#1e293b"
MUTED_COLOR = "#64748b"
BORDER_COLOR = "#e2e8f0"
BACKGROUND_COLOR = "#f8fafc"
WHITE = "#ffffff"
DANGER_COLOR = "#dc2626"
WARNING_COLOR = "#f59e0b"
SUCCESS_COLOR = "#16a34a"
def get_logo_url() -> str:
"""Get the email logo URL from config or use default inline SVG."""
return current_app.config.get("EMAIL_BRAND_LOGO_URL", "")
def get_brand_name() -> str:
"""Get the brand name from config."""
return current_app.config.get("EMAIL_BRAND_NAME", "Secuird")
def get_support_email() -> str:
"""Get the support email from config."""
return current_app.config.get("EMAIL_SUPPORT_EMAIL", "support@secuird.tech")
def get_website_url() -> str:
"""Get the website URL from config."""
return current_app.config.get("EMAIL_WEBSITE_URL", "https://secuird.tech")
def get_app_url() -> str:
"""Get the app URL from config."""
return current_app.config.get("APP_URL", "https://secuird.tech")
def get_inline_logo() -> str:
"""Returns an inline SVG logo as a data URI for email embedding."""
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" '
'width="40" height="40"><rect width="24" height="24" rx="4" fill="#36b9a6"/>'
'<path d="M4 4h3v16H4V4z" fill="#ffffff"/><path d="M17 4h3v16h-3V4z" fill="#ffffff"/>'
'<path d="M7 4h10v3H7V4z" fill="#ffffff" opacity="0.7"/>'
'<circle cx="12" cy="14" r="2" fill="#ffffff" opacity="0.5"/></svg>'
)
def get_base_html(
content: str,
subject: str,
preheader: Optional[str] = None,
) -> str:
"""Generate the base HTML email template.
Args:
content: The main content HTML
subject: Email subject (used for title and header)
preheader: Preview text shown in email clients
Returns:
Complete HTML email string
"""
logo = get_inline_logo()
brand_name = get_brand_name()
support_email = get_support_email()
website_url = get_website_url()
app_url = get_app_url()
current_year = __import__("datetime").datetime.now().year
return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{subject}</title>
<!--[if mso]>
<style type="text/css">
table {{ border-collapse: collapse; }}
.button {{ padding: 12px 24px !important; }}
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: {BACKGROUND_COLOR}; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: {BACKGROUND_COLOR};">
<tr>
<td align="center" style="padding: 40px 20px;">
<!-- Email Container -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; background-color: {WHITE}; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, {PRIMARY_COLOR}, {PRIMARY_DARK}); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td style="text-align: center;">
{logo}
<h1 style="margin: 16px 0 0 0; color: {WHITE}; font-size: 24px; font-weight: 600;">{brand_name}</h1>
</td>
</tr>
</table>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px 32px;">
{content}
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding: 0 32px;">
<hr style="border: none; border-top: 1px solid {BORDER_COLOR}; margin: 0;">
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 32px; background-color: {BACKGROUND_COLOR}; border-radius: 0 0 12px 12px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td style="text-align: center; color: {MUTED_COLOR}; font-size: 13px;">
<p style="margin: 0 0 8px 0;"{current_year} {brand_name}. All rights reserved.</p>
<p style="margin: 0;">
<a href="{website_url}" style="color: {PRIMARY_COLOR}; text-decoration: none;">Website</a>
&nbsp;&bull;&nbsp;
<a href="mailto:{support_email}" style="color: {PRIMARY_COLOR}; text-decoration: none;">Support</a>
&nbsp;&bull;&nbsp;
<a href="{app_url}" style="color: {PRIMARY_COLOR}; text-decoration: none;">App</a>
</p>
<p style="margin: 12px 0 0 0; font-size: 11px; color: #94a3b8;">
This email was sent because you have an account with {brand_name}.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'''
def get_action_button(link: str, text: str, color: str = PRIMARY_COLOR) -> str:
"""Generate an HTML action button.
Args:
link: The URL the button links to
text: Button text
color: Button background color
Returns:
HTML button string
"""
return f'''<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 24px 0;">
<tr>
<td style="border-radius: 8px; background: linear-gradient(135deg, {color}, {color});">
<a href="{link}" style="display: inline-block; padding: 14px 32px; color: {WHITE}; text-decoration: none; font-weight: 600; font-size: 15px; border-radius: 8px;">{text}</a>
</td>
</tr>
</table>'''
def get_alert_box(text: str, alert_type: str = "info", icon: str = "") -> str:
"""Generate an alert/highlight box.
Args:
text: Alert text
alert_type: Type of alert (info, warning, danger, success)
icon: Optional icon emoji or HTML
Returns:
HTML alert box string
"""
colors = {
"info": (PRIMARY_COLOR, "#e0f2f1"),
"warning": (WARNING_COLOR, "#fef3c7"),
"danger": (DANGER_COLOR, "#fee2e2"),
"success": (SUCCESS_COLOR, "#dcfce7"),
}
border_color, bg_color = colors.get(alert_type, colors["info"])
return f'''<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0;">
<tr>
<td style="background-color: {bg_color}; border-left: 4px solid {border_color}; padding: 16px 20px; border-radius: 0 8px 8px 0;">
<p style="margin: 0; color: {TEXT_COLOR}; font-size: 14px;">{icon} {text}</p>
</td>
</tr>
</table>'''
def get_detail_row(label: str, value: str) -> str:
"""Generate a detail row for email content.
Args:
label: Field label
value: Field value
Returns:
HTML row string
"""
return f'''<tr>
<td style="padding: 8px 0; border-bottom: 1px solid {BORDER_COLOR};">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td width="40%" style="color: {MUTED_COLOR}; font-size: 13px;">{label}</td>
<td width="60%" style="color: {TEXT_COLOR}; font-size: 14px; font-weight: 500;">{value}</td>
</tr>
</table>
</td>
</tr>'''
# =============================================================================
# EMAIL TEMPLATES
# =============================================================================
def build_email_verification_html(
user_name: str,
verify_link: str,
expiry_hours: int = 24,
) -> str:
"""Build email verification email (welcome email).
Args:
user_name: Recipient's name or email
verify_link: Verification link URL
expiry_hours: Hours until link expires
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Welcome to Secuird!</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Hi <strong>{user_name}</strong>,
</p>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Thank you for registering with Secuird. Please verify your email address by clicking the button below:
</p>
{get_action_button(verify_link, "Verify Email Address")}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
This link will expire in <strong>{expiry_hours} hours</strong>. If you didn't create an account, you can safely ignore this email.
</p>
{get_alert_box("For security reasons, please don't forward this email to anyone.", "warning", "⚠️")}
'''
return get_base_html(content, "Verify your Secuird email address", "Please verify your email address to activate your account")
def build_password_reset_html(
user_name: str,
reset_link: str,
expiry_hours: int = 2,
) -> str:
"""Build password reset email.
Args:
user_name: Recipient's name or email
reset_link: Password reset link URL
expiry_hours: Hours until link expires
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Reset Your Password</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Hi <strong>{user_name}</strong>,
</p>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
We received a request to reset your password. Click the button below to create a new one:
</p>
{get_action_button(reset_link, "Reset Password", WARNING_COLOR)}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
This link will expire in <strong>{expiry_hours} hours</strong>.
</p>
{get_alert_box("If you didn't request a password reset, your account is secure. You can safely ignore this email.", "info", "🔒")}
'''
return get_base_html(content, "Reset your Secuird password", "Click the button to reset your password")
def build_account_activation_html(
user_name: str,
activation_link: str,
) -> str:
"""Build account activation email.
Args:
user_name: Recipient's name or email
activation_link: Account activation link URL
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Activate Your Account</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Hi <strong>{user_name}</strong>,
</p>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Your account has been created but is not yet activated. Click the button below to activate it:
</p>
{get_action_button(activation_link, "Activate Account", SUCCESS_COLOR)}
{get_alert_box("If you didn't create an account, you can safely ignore this email.", "warning", "⚠️")}
'''
return get_base_html(content, "Activate your Secuird account", "Activate your account to get started")
def build_mfa_deadline_reminder_html(
user_name: str,
org_name: str,
days_remaining: int,
deadline_date: str,
mfa_methods: str,
setup_link: str,
) -> str:
"""Build MFA deadline reminder email.
Args:
user_name: Recipient's name or email
org_name: Organization name
days_remaining: Days until MFA deadline
deadline_date: Formatted deadline date
mfa_methods: Required MFA methods
setup_link: Link to set up MFA
Returns:
HTML email string
"""
urgency = "immediate action" if days_remaining <= 3 else "attention required"
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">MFA Enrollment {urgency.title()}</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Dear <strong>{user_name}</strong>,
</p>
{get_alert_box(f"<strong>Important:</strong> You have <strong>{days_remaining} days</strong> to set up multi-factor authentication for your account with {org_name}.", "warning", "")}
<p style="margin: 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
To maintain access to your account, please complete the following:
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0; background-color: {BACKGROUND_COLOR}; border-radius: 8px;">
<tr>
<td style="padding: 20px;">
<h3 style="margin: 0 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Required MFA Methods:</h3>
<p style="margin: 0; color: {MUTED_COLOR}; font-size: 14px;">{mfa_methods}</p>
<h3 style="margin: 16px 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Deadline:</h3>
<p style="margin: 0; color: {DANGER_COLOR}; font-size: 14px; font-weight: 600;">{deadline_date}</p>
</td>
</tr>
</table>
{get_action_button(setup_link, "Set Up MFA Now", PRIMARY_COLOR)}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
If you do not set up MFA by the deadline, your account access will be restricted.
</p>
<p style="margin: 0; color: {MUTED_COLOR}; font-size: 13px;">
If you have questions, please contact your organization administrator.
</p>
'''
subject = f"Action Required: MFA enrollment deadline in {days_remaining} days"
return get_base_html(content, subject, f"MFA enrollment required for {org_name} - {days_remaining} days remaining")
def build_mfa_suspension_html(
user_name: str,
org_name: str,
mfa_methods: str,
setup_link: str,
) -> str:
"""Build MFA suspension notification email.
Args:
user_name: Recipient's name or email
org_name: Organization name
mfa_methods: Required MFA methods
setup_link: Link to set up MFA
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {DANGER_COLOR}; font-size: 20px; font-weight: 600;">Account Access Restricted</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Dear <strong>{user_name}</strong>,
</p>
{get_alert_box("<strong>Your account has been suspended</strong> because you did not set up multi-factor authentication within the required timeframe.", "danger", "🚫")}
<p style="margin: 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
To restore access to your account with <strong>{org_name}</strong>, please complete the following:
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0; background-color: {BACKGROUND_COLOR}; border-radius: 8px;">
<tr>
<td style="padding: 20px;">
<h3 style="margin: 0 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Required MFA Methods:</h3>
<p style="margin: 0; color: {MUTED_COLOR}; font-size: 14px;">{mfa_methods}</p>
<h3 style="margin: 16px 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">How to Restore Access:</h3>
<ol style="margin: 0; padding-left: 20px; color: {TEXT_COLOR}; font-size: 14px; line-height: 1.8;">
<li>Log in to your account (you will see a compliance enrollment screen)</li>
<li>Follow the prompts to set up an authenticator app or passkey</li>
<li>Once MFA is configured, your access will be restored</li>
</ol>
</td>
</tr>
</table>
{get_action_button(setup_link, "Set Up MFA Now", DANGER_COLOR)}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
Need help? Contact your organization administrator.
</p>
'''
return get_base_html(content, "Account Access Restricted - MFA Enrollment Required", "Your account has been suspended due to missing MFA")
def build_org_invite_html(
inviter_name: str,
org_name: str,
invite_link: str,
role: str,
expiry_days: int = 7,
) -> str:
"""Build organization invite email.
Args:
inviter_name: Name of person who sent the invite
org_name: Organization name
invite_link: Invitation acceptance link
role: Role the invitee will have
expiry_days: Days until invite expires
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">You're Invited to Join {org_name}</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
You've been invited by <strong>{inviter_name}</strong> to join <strong>{org_name}</strong> on Secuird.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0; background-color: {BACKGROUND_COLOR}; border-radius: 8px;">
<tr>
<td style="padding: 20px;">
<h3 style="margin: 0 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Invitation Details:</h3>
<p style="margin: 0; color: {TEXT_COLOR}; font-size: 14px;"><strong>Organization:</strong> {org_name}</p>
<p style="margin: 8px 0 0 0; color: {TEXT_COLOR}; font-size: 14px;"><strong>Role:</strong> {role}</p>
<p style="margin: 8px 0 0 0; color: {MUTED_COLOR}; font-size: 13px;">This invitation expires in {expiry_days} days</p>
</td>
</tr>
</table>
{get_action_button(invite_link, "Accept Invitation", SUCCESS_COLOR)}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
If you did not expect this invitation, you can safely ignore this email.
</p>
'''
return get_base_html(content, f"You're invited to join {org_name} on Secuird", f"You've been invited to join {org_name}")
def build_email_verification_resend_html(
user_name: str,
verify_link: str,
expiry_hours: int = 24,
) -> str:
"""Build email verification resend email.
Args:
user_name: Recipient's name or email
verify_link: Verification link URL
expiry_hours: Hours until link expires
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Verify Your Email Address</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Hi <strong>{user_name}</strong>,
</p>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Please verify your email address by clicking the button below:
</p>
{get_action_button(verify_link, "Verify Email Address")}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
This link will expire in <strong>{expiry_hours} hours</strong>.
</p>
{get_alert_box("If you didn't request this, you can safely ignore this email.", "info", "🔒")}
'''
return get_base_html(content, "Verify your Secuird email address", "Please verify your email address")