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
+10 -2
View File
@@ -20,13 +20,13 @@ class DevelopmentConfig(BaseConfig):
# Reduced bcrypt rounds for faster dev cycles
BCRYPT_LOG_ROUNDS = 4
# Gatehouse React UI URL — OIDC authorize redirects here instead of showing raw HTML
# Secuird React UI URL — OIDC authorize redirects here instead of showing raw HTML
OIDC_UI_URL = os.getenv("OIDC_UI_URL", "http://localhost:8080")
# Add localhost:8080 (React UI) to CORS allowed origins for OIDC bridge endpoints
CORS_ORIGINS = os.getenv(
"CORS_ORIGINS",
"http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local"
"http://192.168.50.124:8080,http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local"
).split(",")
# ── Email / SMTP ──────────────────────────────────────────────────────────
@@ -40,3 +40,11 @@ class DevelopmentConfig(BaseConfig):
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "").lower() == "true" if os.getenv("SMTP_USE_TLS") else int(os.getenv("SMTP_PORT", "1025")) not in (25, 1025)
FROM_ADDRESS = os.getenv("FROM_ADDRESS", "noreply@gatehouse.local")
EMAIL_FROM = FROM_ADDRESS # alias
# Email Provider Configuration
EMAIL_PROVIDER = os.getenv("EMAIL_PROVIDER", "smtp").lower()
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY") or None
MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN") or None
MAILGUN_API_URL = os.getenv("MAILGUN_API_URL", "https://api.mailgun.net/v3")
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY") or None
SENDGRID_FROM_EMAIL = os.getenv("SENDGRID_FROM_EMAIL") or None
+7 -1
View File
@@ -191,6 +191,8 @@ def setup_logging(app):
root_logger.setLevel(log_level)
if app.config.get("LOG_TO_STDOUT"):
# Clear existing handlers on root logger to avoid duplicates
root_logger.handlers.clear()
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
stream_handler.setLevel(log_level)
@@ -207,7 +209,8 @@ def setup_logging(app):
child_logger.propagate = True
child_logger.setLevel(log_level)
# Configure Flask app logger
# Configure Flask app logger - clear handlers so it only propagates to root
app.logger.handlers.clear()
app.logger.setLevel(log_level)
# Configure SQLAlchemy logging level (also set at module level before DB init)
@@ -217,6 +220,9 @@ def setup_logging(app):
logging.getLogger('sqlalchemy.dialects').setLevel(sqlalchemy_log_level)
logging.getLogger('sqlalchemy.pool').setLevel(sqlalchemy_log_level)
# Suppress watchdog debug logging
logging.getLogger('watchdog.observers.inotify_buffer').setLevel(logging.INFO)
app.logger.info("Application startup")
# Test debug log after logging is configured
+3 -3
View File
@@ -32,12 +32,12 @@ def register():
verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
subject = "Verify your Gatehouse email address"
subject = "Verify your Secuird email address"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"Welcome to Gatehouse! Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"Welcome to Secuird! Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"{verify_link}\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
)
NotificationService._send_email_async(to_address=user.email, subject=subject, body=body)
except Exception as exc:
+8 -8
View File
@@ -29,14 +29,14 @@ def forgot_password():
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
NotificationService._send_email_async(
to_address=user.email,
subject="Reset your Gatehouse password",
subject="Reset your Secuird password",
body=(
f"Hi {user.full_name or user.email},\n\n"
f"You requested a password reset for your Gatehouse account.\n\n"
f"You requested a password reset for your Secuird account.\n\n"
f"Click the link below to reset your password (valid for 2 hours):\n"
f"{reset_link}\n\n"
f"If you did not request this, you can safely ignore this email.\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
),
)
_logger.info(f"Password reset token generated for user {user.id}")
@@ -131,12 +131,12 @@ def resend_verification():
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
NotificationService._send_email_async(
to_address=user.email,
subject="Verify your Gatehouse email address",
subject="Verify your Secuird email address",
body=(
f"Hi {user.full_name or user.email},\n\n"
f"Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"{verify_link}\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
),
)
_logger.info(f"Verification email sent for user {user.id}")
@@ -202,13 +202,13 @@ def resend_activation():
activate_link = f"{app_url}/activate?code={code}"
NotificationService._send_email_async(
to_address=user.email,
subject="Activate your Gatehouse account",
subject="Activate your Secuird account",
body=(
f"Hi {user.full_name or user.email},\n\n"
f"Please activate your Gatehouse account by clicking the link below:\n"
f"Please activate your Secuird account by clicking the link below:\n"
f"{activate_link}\n\n"
f"If you did not create an account, you can safely ignore this email.\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
),
)
_logger.info(f"Activation email re-sent to {user.id}")
@@ -39,12 +39,12 @@ def create_org_invite(org_id):
NotificationService._send_email_async(
to_address=email,
subject=f"You're invited to join {org.name} on Gatehouse",
subject=f"You're invited to join {org.name} on Secuird",
body=(
f"You've been invited to join {org.name} on Gatehouse.\n\n"
f"You've been invited to join {org.name} on Secuird.\n\n"
f"Click the link below to accept the invitation (valid for 7 days):\n"
f"{invite_link}\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
),
)
logging.getLogger(__name__).info(f"[INVITE] Email queued for {email}")
@@ -167,9 +167,9 @@ def send_mfa_reminder(org_id, user_id):
body=(
f"Hi {user.full_name or user.email},\n\n"
"Your organization administrator has asked you to set up "
"multi-factor authentication (MFA) on your Gatehouse account.\n\n"
"multi-factor authentication (MFA) on your Secuird account.\n\n"
"Please log in and configure MFA as soon as possible.\n\n"
"Gatehouse Security Team"
"Secuird Security Team"
),
)
+1 -1
View File
@@ -294,7 +294,7 @@ class AuthService:
provisioning_uri = TOTPService.generate_provisioning_uri(
user_email=user.email,
secret=secret,
issuer="Gatehouse",
issuer="Secuird",
)
# Generate QR code data URI
+85
View File
@@ -0,0 +1,85 @@
"""Email provider interfaces and factory."""
import logging
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
@dataclass
class EmailMessage:
"""Email message data structure."""
to: str
subject: str
body: str
html_body: Optional[str] = None
from_address: Optional[str] = None
class EmailProvider(ABC):
"""Abstract base class for email providers."""
@abstractmethod
def send(self, message: EmailMessage) -> bool:
"""
Send an email message.
Args:
message: EmailMessage instance containing email details
Returns:
bool: True if email was sent successfully, False otherwise
"""
pass
class NoOpEmailProvider(EmailProvider):
"""No-op email provider that logs and returns False."""
def send(self, message: EmailMessage) -> bool:
"""Log that emails are disabled and return False."""
logger.info(f"Email disabled - would send to={message.to} subject={message.subject}")
return False
class EmailProviderFactory:
"""Factory for creating email provider instances."""
@staticmethod
def get_provider() -> EmailProvider:
"""
Create an email provider based on EMAIL_PROVIDER config.
Returns:
EmailProvider: An instance of the appropriate email provider
"""
provider_name = os.getenv("EMAIL_PROVIDER", "smtp").lower()
if provider_name == "smtp":
try:
from gatehouse_app.services.providers.smtp_provider import SmtpEmailProvider
return SmtpEmailProvider()
except ImportError:
logger.warning("SMTP provider not implemented, using no-op provider")
return NoOpEmailProvider()
if provider_name == "mailgun":
try:
from gatehouse_app.services.providers.mailgun_provider import MailgunEmailProvider
return MailgunEmailProvider()
except ImportError:
logger.warning("Mailgun provider not implemented, using no-op provider")
return NoOpEmailProvider()
if provider_name == "sendgrid":
try:
from gatehouse_app.services.providers.sendgrid_provider import SendGridEmailProvider
return SendGridEmailProvider()
except ImportError:
logger.warning("SendGrid provider not implemented, using no-op provider")
return NoOpEmailProvider()
logger.error(f"Invalid EMAIL_PROVIDER value: {provider_name}, defaulting to no-op provider")
return NoOpEmailProvider()
+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")
@@ -136,7 +136,7 @@ def complete_link_flow(
).first()
if conflicting:
raise ExternalAuthError(
f"This {provider_type_str} account is already linked to a different Gatehouse user.",
f"This {provider_type_str} account is already linked to a different Secuird user.",
"PROVIDER_ALREADY_LINKED",
409,
)
@@ -246,10 +246,10 @@ def authenticate_with_provider(
provider_user_id=user_info["provider_user_id"],
email=user_info["email"],
failure_reason="account_not_found",
error_message="No Gatehouse account matches this external account",
error_message="No Secuird account matches this external account",
)
raise ExternalAuthError(
"No Gatehouse account matches this external account. Please register first.",
"No Secuird account matches this external account. Please register first.",
"ACCOUNT_NOT_FOUND",
400,
)
+18 -49
View File
@@ -24,6 +24,7 @@ from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyComplia
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.user.user import User
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.email_provider import EmailMessage, EmailProviderFactory
from gatehouse_app.utils.constants import AuditAction
logger = logging.getLogger(__name__)
@@ -207,7 +208,7 @@ If you do not set up MFA by the deadline, your account access will be restricted
If you have any questions, please contact your organization administrator.
Best regards,
Gatehouse Security Team
Secuird Security Team
"""
return body
@@ -266,7 +267,7 @@ As a result, your account has been placed in a suspended state.
Contact your organization administrator if you have questions.
Best regards,
Gatehouse Security Team
Secuird Security Team
"""
return body
@@ -280,12 +281,9 @@ Gatehouse Security Team
"""Send an email on a daemon thread so the calling request returns immediately.
If EMAIL_ENABLED is False, logs instead of sending.
All SMTP exceptions are caught and logged — this method never raises.
All email provider exceptions are caught and logged — this method never raises.
The Flask app context is pushed inside the thread so current_app works correctly.
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from flask import current_app
app = current_app._get_current_object() # capture real app before leaving request context
@@ -295,58 +293,29 @@ Gatehouse Security Team
email_enabled = app.config.get(NotificationService.EMAIL_ENABLED_KEY, False)
if not email_enabled:
logger.info(
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n"
f"Body: {body[:500]}"
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}"
)
return
smtp_host = app.config.get(NotificationService.SMTP_HOST_KEY, "")
smtp_port_raw = app.config.get(NotificationService.SMTP_PORT_KEY, 587)
smtp_username = app.config.get(NotificationService.SMTP_USERNAME_KEY)
smtp_password = app.config.get(NotificationService.SMTP_PASSWORD_KEY)
from_address = app.config.get(NotificationService.FROM_ADDRESS_KEY, "")
missing = [k for k, v in [("SMTP_HOST", smtp_host), ("FROM_ADDRESS", from_address)] if not v]
if missing:
logger.error(
f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
f"Would have sent to: {to_address} | Subject: {subject}"
)
return
try:
smtp_port = int(smtp_port_raw)
except (TypeError, ValueError):
logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}")
return
smtp_use_tls = app.config.get(
NotificationService.SMTP_USE_TLS_KEY,
smtp_port not in (25, 1025),
# Build email message
message = EmailMessage(
to=to_address,
subject=subject,
body=body,
html_body=html_body,
from_address=from_address,
)
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_address
msg["To"] = to_address
msg.attach(MIMEText(body, "plain"))
if html_body:
msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
if smtp_use_tls:
server.starttls()
server.ehlo()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
# Get provider and send
provider = EmailProviderFactory.get_provider()
success = provider.send(message)
if success:
logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}")
except Exception as e:
logger.error(f"[EMAIL] Failed to send to {to_address}: {e}")
else:
logger.error(f"[EMAIL] Failed to send to {to_address}")
threading.Thread(target=_send, daemon=True).start()
@@ -0,0 +1,83 @@
"""Mailgun email provider implementation."""
import logging
import requests
from flask import current_app
from gatehouse_app.services.email_provider import EmailMessage, EmailProvider
logger = logging.getLogger(__name__)
class MailgunEmailProvider(EmailProvider):
"""Mailgun API-based email provider implementation."""
# Configuration keys
MAILGUN_API_KEY = "MAILGUN_API_KEY"
MAILGUN_DOMAIN = "MAILGUN_DOMAIN"
MAILGUN_API_URL = "MAILGUN_API_URL"
FROM_ADDRESS = "FROM_ADDRESS"
DEFAULT_API_URL = "https://api.mailgun.net/v3"
def send(self, message: EmailMessage) -> bool:
"""Send an email via Mailgun API.
Args:
message: EmailMessage instance containing email details
Returns:
bool: True if email was sent successfully, False otherwise
"""
api_key = current_app.config.get(self.MAILGUN_API_KEY)
domain = current_app.config.get(self.MAILGUN_DOMAIN)
api_url = current_app.config.get(self.MAILGUN_API_URL, self.DEFAULT_API_URL)
default_from = current_app.config.get(self.FROM_ADDRESS)
missing = [k for k, v in [("MAILGUN_API_KEY", api_key), ("MAILGUN_DOMAIN", domain)] if not v]
if missing:
logger.error(
f"[MAILGUN] Cannot send — missing config: {', '.join(missing)}. "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
from_address = message.from_address or default_from
if not from_address:
logger.error(
f"[MAILGUN] Cannot send — missing FROM_ADDRESS. "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
url = f"{api_url}/{domain}/messages"
data = {
"to": message.to,
"subject": message.subject,
"text": message.body,
"from": from_address,
}
if message.html_body:
data["html"] = message.html_body
try:
response = requests.post(
url,
auth=("api", api_key),
data=data,
)
if response.status_code == 200:
logger.info(f"[MAILGUN] Sent to {message.to} | Subject: {message.subject}")
return True
else:
logger.error(
f"[MAILGUN] Failed to send to {message.to}: from {from_address}"
f"status={response.status_code} body={response.text}"
)
return False
except Exception as e:
logger.error(f"[MAILGUN] Exception while sending to {message.to}: {e}")
return False
@@ -0,0 +1,94 @@
"""SendGrid email provider implementation."""
import logging
import requests
from flask import current_app
from gatehouse_app.services.email_provider import EmailMessage, EmailProvider
logger = logging.getLogger(__name__)
class SendGridEmailProvider(EmailProvider):
"""SendGrid API-based email provider implementation."""
# Configuration keys
SENDGRID_API_KEY = "SENDGRID_API_KEY"
SENDGRID_FROM_EMAIL = "SENDGRID_FROM_EMAIL"
FROM_ADDRESS = "FROM_ADDRESS"
API_URL = "https://api.sendgrid.com/v3/mail/send"
def send(self, message: EmailMessage) -> bool:
"""Send an email via SendGrid API.
Args:
message: EmailMessage instance containing email details
Returns:
bool: True if email was sent successfully, False otherwise
"""
api_key = current_app.config.get(self.SENDGRID_API_KEY)
default_from = current_app.config.get(self.SENDGRID_FROM_EMAIL)
fallback_from = current_app.config.get(self.FROM_ADDRESS)
if not api_key:
logger.error(
f"[SENDGRID] Cannot send — missing SENDGRID_API_KEY config. "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
from_address = message.from_address or default_from or fallback_from
if not from_address:
logger.error(
f"[SENDGRID] Cannot send — missing from address (SENDGRID_FROM_EMAIL or FROM_ADDRESS). "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
payload = {
"personalizations": [
{
"to": [{"email": message.to}]
}
],
"from": {"email": from_address},
"subject": message.subject,
"content": [
{
"type": "text/plain",
"value": message.body
}
]
}
if message.html_body:
payload["content"].append({
"type": "text/html",
"value": message.html_body
})
try:
response = requests.post(
self.API_URL,
json=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
)
if response.status_code == 202:
logger.info(f"[SENDGRID] Sent to {message.to} | Subject: {message.subject}")
return True
else:
logger.error(
f"[SENDGRID] Failed to send to {message.to}: "
f"status={response.status_code} body={response.text}"
)
return False
except Exception as e:
logger.error(f"[SENDGRID] Exception while sending to {message.to}: {e}")
return False
@@ -0,0 +1,92 @@
"""SMTP email provider implementation."""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from flask import current_app
from gatehouse_app.services.email_provider import EmailMessage, EmailProvider
logger = logging.getLogger(__name__)
class SmtpEmailProvider(EmailProvider):
"""SMTP-based email provider implementation."""
# Configuration keys
EMAIL_ENABLED_KEY = "EMAIL_ENABLED"
SMTP_HOST_KEY = "SMTP_HOST"
SMTP_PORT_KEY = "SMTP_PORT"
SMTP_USERNAME_KEY = "SMTP_USERNAME"
SMTP_PASSWORD_KEY = "SMTP_PASSWORD"
SMTP_USE_TLS_KEY = "SMTP_USE_TLS"
FROM_ADDRESS_KEY = "FROM_ADDRESS"
def send(self, message: EmailMessage) -> bool:
"""Send an email via SMTP.
Args:
message: EmailMessage instance containing email details
Returns:
bool: True if email was sent successfully, False otherwise
"""
email_enabled = current_app.config.get(self.EMAIL_ENABLED_KEY, False)
if not email_enabled:
logger.info(
f"[EMAIL DISABLED] Would have sent to: {message.to} | "
f"Subject: {message.subject}"
)
return False
smtp_host = current_app.config.get(self.SMTP_HOST_KEY, "")
from_address = message.from_address or current_app.config.get(self.FROM_ADDRESS_KEY, "")
missing = [k for k, v in [("SMTP_HOST", smtp_host), ("FROM_ADDRESS", from_address)] if not v]
if missing:
logger.error(
f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
smtp_port_raw = current_app.config.get(self.SMTP_PORT_KEY, 587)
try:
smtp_port = int(smtp_port_raw)
except (TypeError, ValueError):
logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}")
return False
smtp_username = current_app.config.get(self.SMTP_USERNAME_KEY)
smtp_password = current_app.config.get(self.SMTP_PASSWORD_KEY)
smtp_use_tls = current_app.config.get(
self.SMTP_USE_TLS_KEY,
smtp_port not in (25, 1025),
)
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = message.subject
msg["From"] = from_address
msg["To"] = message.to
msg.attach(MIMEText(message.body, "plain"))
if message.html_body:
msg.attach(MIMEText(message.html_body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
if smtp_use_tls:
server.starttls()
server.ehlo()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
logger.info(f"[EMAIL] Sent to {message.to} | Subject: {message.subject}")
return True
except Exception as e:
logger.error(f"[EMAIL] Failed to send to {message.to}: {e}")
return False
+3 -3
View File
@@ -75,14 +75,14 @@ class TOTPService:
return secret
@staticmethod
def generate_provisioning_uri(user_email: str, secret: str, issuer: str = "Gatehouse") -> str:
def generate_provisioning_uri(user_email: str, secret: str, issuer: str = "Secuird") -> str:
"""
Generate provisioning URI for QR code.
Args:
user_email: User's email address
secret: TOTP secret (base32 encoded)
issuer: Issuer name (default: "Gatehouse")
issuer: Issuer name (default: "Secuird")
Returns:
otpauth:// URI for QR code generation
@@ -90,7 +90,7 @@ class TOTPService:
Example:
>>> uri = TOTPService.generate_provisioning_uri("user@example.com", "JBSWY3DPEHPK3PXP")
>>> print(uri)
otpauth://totp/Gatehouse:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Gatehouse
otpauth://totp/Secuird:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Secuird
"""
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=user_email, issuer_name=issuer)
+1 -1
View File
@@ -167,7 +167,7 @@ class WebAuthnService:
# Get RP configuration
rp_id = current_app.config.get('WEBAUTHN_RP_ID', 'localhost')
rp_name = current_app.config.get('WEBAUTHN_RP_NAME', 'Gatehouse')
rp_name = current_app.config.get('WEBAUTHN_RP_NAME', 'Secuird')
# Generate user ID (Base64URL encoded)
user_id = cls._base64url_encode(user.id.encode('utf-8'))
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Test script to verify email delivery with HTML templates."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from dotenv import load_dotenv
load_dotenv(".env")
from gatehouse_app import create_app
from gatehouse_app.services.email_provider import EmailProviderFactory, EmailMessage
from gatehouse_app.services import email_templates
def test_html_email():
app = create_app()
print("Testing HTML Email Templates...")
print(f"EMAIL_PROVIDER: {app.config.get('EMAIL_PROVIDER')}")
print(f"MAILGUN_DOMAIN: {app.config.get('MAILGUN_DOMAIN')}")
with app.app_context():
provider = EmailProviderFactory.get_provider()
print(f"Provider class: {provider.__class__.__name__}")
# Test 1: Email Verification
print("\n--- Test 1: Email Verification ---")
html_body = email_templates.build_email_verification_html(
user_name="Cory",
verify_link="https://secuird.tech/verify-email?token=test123",
expiry_hours=24,
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Verify your Secuird email address",
body="Plain text version: Please verify your email by clicking the link.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 2: Password Reset
print("\n--- Test 2: Password Reset ---")
html_body = email_templates.build_password_reset_html(
user_name="Cory",
reset_link="https://secuird.tech/reset-password?token=test456",
expiry_hours=2,
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Reset your Secuird password",
body="Plain text version: Reset your password by clicking the link.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 3: MFA Deadline Reminder
print("\n--- Test 3: MFA Deadline Reminder ---")
html_body = email_templates.build_mfa_deadline_reminder_html(
user_name="Cory",
org_name="Acme Corp",
days_remaining=5,
deadline_date="2026-04-09 23:59 UTC",
mfa_methods="Authenticator app (TOTP) or Passkey (WebAuthn)",
setup_link="https://secuird.tech/settings/security",
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Action Required: MFA enrollment deadline in 5 days",
body="Plain text version: MFA enrollment required.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 4: MFA Suspension
print("\n--- Test 4: MFA Suspension ---")
html_body = email_templates.build_mfa_suspension_html(
user_name="Cory",
org_name="Acme Corp",
mfa_methods="Authenticator app (TOTP) or Passkey (WebAuthn)",
setup_link="https://secuird.tech/settings/security",
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Account Access Restricted - MFA Enrollment Required",
body="Plain text version: Your account has been suspended.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 5: Organization Invite
print("\n--- Test 5: Organization Invite ---")
html_body = email_templates.build_org_invite_html(
inviter_name="Admin User",
org_name="Acme Corporation",
invite_link="https://secuird.tech/invite?token=test789",
role="Member",
expiry_days=7,
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="You're invited to join Acme Corporation on Secuird",
body="Plain text version: You've been invited to join.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 6: Account Activation
print("\n--- Test 6: Account Activation ---")
html_body = email_templates.build_account_activation_html(
user_name="Cory",
activation_link="https://secuird.tech/activate?code=testabc",
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Activate your Secuird account",
body="Plain text version: Activate your account.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 7: Email Verification Resend
print("\n--- Test 7: Email Verification Resend ---")
html_body = email_templates.build_email_verification_resend_html(
user_name="Cory",
verify_link="https://secuird.tech/verify-email?token=testxyz",
expiry_hours=24,
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Verify your Secuird email address",
body="Plain text version: Please verify your email.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
print("\n" + "=" * 50)
print("All 7 email templates sent!")
print("=" * 50)
if __name__ == "__main__":
test_html_email()