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:
+10
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
•
|
||||
<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")
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user