Files
gatehouse-api/gatehouse_app/services/email_templates.py
T
coryHawkvelt 02e95a4199 feat(organizations): email inviter when membership invite is accepted
When a user accepts an org invite, send a notification email to the
person who sent the invite with membership details (member name, email,
org name, role) and an optional View Organization button.

Added build_invite_accepted_html() template to email_templates.py,
wired it into the accept_invite() handler, and added a test case.
2026-04-26 18:36:58 +09:30

613 lines
25 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>
&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")
def build_contact_enquiry_html(
enquiry_type: str,
submitter_email: str,
name: Optional[str],
company: Optional[str],
interest_area: Optional[str],
message: Optional[str],
) -> str:
"""Build a contact enquiry notification email.
Args:
enquiry_type: One of demo_request, sales_enquiry, general, support
submitter_email: Email address of the person submitting the enquiry
name: Full name of the submitter (optional)
company: Company name (optional)
interest_area: Area of interest (optional)
message: Free-text message (optional)
Returns:
HTML email string
"""
# Map enquiry types to display labels and colors
type_labels = {
"demo_request": ("Demo Request", "info"),
"sales_enquiry": ("Sales Enquiry", "success"),
"general": ("General Enquiry", "info"),
"support": ("Support Request", "warning"),
}
type_label, alert_type = type_labels.get(enquiry_type, ("Enquiry", "info"))
name_display = name if name else "Not provided"
company_display = company if company else "Not provided"
interest_display = interest_area if interest_area else "Not provided"
message_display = message if message else "No message provided"
# Build details table
details_rows = f"""
{get_detail_row("Enquiry Type", type_label)}
{get_detail_row("Submitter Email", submitter_email)}
{get_detail_row("Name", name_display)}
{get_detail_row("Company", company_display)}
{get_detail_row("Interest Area", interest_display)}
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">New {type_label}</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
A new {type_label.lower()} has been submitted through the Secuird website.
</p>
{get_alert_box(f"Enquiry type: <strong>{type_label}</strong>", alert_type, "📬")}
<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 16px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Enquiry Details</h3>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
{details_rows}
</table>
</td>
</tr>
</table>
<h3 style="margin: 20px 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Message</h3>
<p style="margin: 0; color: {TEXT_COLOR}; font-size: 14px; line-height: 1.6; white-space: pre-wrap;">{message_display}</p>
'''
return get_base_html(content, f"Secuird Website: {type_label}", f"New {type_label} from {submitter_email}")
def build_invite_accepted_html(
inviter_name: str,
member_name: str,
member_email: str,
org_name: str,
role: str,
org_link: Optional[str] = None,
) -> str:
"""Build invite accepted notification email.
Args:
inviter_name: Name of the person who sent the invite
member_name: Name of the person who accepted
member_email: Email of the person who accepted
org_name: Organization name
role: Role assigned to the member
org_link: Optional link to view the organization
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Invitation Accepted</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
<strong>{member_name}</strong> has accepted your invitation to join <strong>{org_name}</strong> on Secuird.
</p>
{get_alert_box(f"<strong>{member_name}</strong> ({member_email}) has joined <strong>{org_name}</strong>", "success", "")}
<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 16px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Membership Details</h3>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
{get_detail_row("Member", member_name)}
{get_detail_row("Email", member_email)}
{get_detail_row("Organization", org_name)}
{get_detail_row("Role", role)}
</table>
</td>
</tr>
</table>
'''
if org_link:
content += get_action_button(org_link, "View Organization", PRIMARY_COLOR)
return get_base_html(content, f"Invitation accepted: {org_name}", f"{member_name} has joined {org_name}")