41bbdb4bef
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.
499 lines
21 KiB
Python
499 lines
21 KiB
Python
"""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>
|
|
•
|
|
<a href="mailto:{support_email}" style="color: {PRIMARY_COLOR}; text-decoration: none;">Support</a>
|
|
•
|
|
<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")
|