diff --git a/config/development.py b/config/development.py index a622243..714a2f3 100644 --- a/config/development.py +++ b/config/development.py @@ -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 diff --git a/gatehouse_app/__init__.py b/gatehouse_app/__init__.py index f17c784..dd77dce 100644 --- a/gatehouse_app/__init__.py +++ b/gatehouse_app/__init__.py @@ -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 diff --git a/gatehouse_app/api/v1/auth/core.py b/gatehouse_app/api/v1/auth/core.py index 308633b..3c0edc1 100644 --- a/gatehouse_app/api/v1/auth/core.py +++ b/gatehouse_app/api/v1/auth/core.py @@ -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: diff --git a/gatehouse_app/api/v1/auth/password.py b/gatehouse_app/api/v1/auth/password.py index fac32fa..0c34a17 100644 --- a/gatehouse_app/api/v1/auth/password.py +++ b/gatehouse_app/api/v1/auth/password.py @@ -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}") diff --git a/gatehouse_app/api/v1/organizations/invites.py b/gatehouse_app/api/v1/organizations/invites.py index 4e3971e..f4ee1fd 100644 --- a/gatehouse_app/api/v1/organizations/invites.py +++ b/gatehouse_app/api/v1/organizations/invites.py @@ -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}") diff --git a/gatehouse_app/api/v1/organizations/members.py b/gatehouse_app/api/v1/organizations/members.py index c605104..3198237 100644 --- a/gatehouse_app/api/v1/organizations/members.py +++ b/gatehouse_app/api/v1/organizations/members.py @@ -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" ), ) diff --git a/gatehouse_app/services/auth_service.py b/gatehouse_app/services/auth_service.py index fdf09b7..04ec8b0 100644 --- a/gatehouse_app/services/auth_service.py +++ b/gatehouse_app/services/auth_service.py @@ -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 diff --git a/gatehouse_app/services/email_provider.py b/gatehouse_app/services/email_provider.py new file mode 100644 index 0000000..331ddd9 --- /dev/null +++ b/gatehouse_app/services/email_provider.py @@ -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() diff --git a/gatehouse_app/services/email_templates.py b/gatehouse_app/services/email_templates.py new file mode 100644 index 0000000..c4ede14 --- /dev/null +++ b/gatehouse_app/services/email_templates.py @@ -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 ( + '' + '' + '' + '' + ) + + +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''' + + + + + + {subject} + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ {logo} +

{brand_name}

+
+
+ {content} +
+
+
+ + + + +
+

© {current_year} {brand_name}. All rights reserved.

+

+ Website +  •  + Support +  •  + App +

+

+ This email was sent because you have an account with {brand_name}. +

+
+
+
+ +''' + + +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''' + + + +
+ {text} +
''' + + +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''' + + + +
+

{icon} {text}

+
''' + + +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''' + + + + + + +
{label}{value}
+ + ''' + + +# ============================================================================= +# 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''' +

Welcome to Secuird!

+

+ Hi {user_name}, +

+

+ Thank you for registering with Secuird. Please verify your email address by clicking the button below: +

+ {get_action_button(verify_link, "Verify Email Address")} +

+ This link will expire in {expiry_hours} hours. If you didn't create an account, you can safely ignore this email. +

+ {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''' +

Reset Your Password

+

+ Hi {user_name}, +

+

+ We received a request to reset your password. Click the button below to create a new one: +

+ {get_action_button(reset_link, "Reset Password", WARNING_COLOR)} +

+ This link will expire in {expiry_hours} hours. +

+ {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''' +

Activate Your Account

+

+ Hi {user_name}, +

+

+ Your account has been created but is not yet activated. Click the button below to activate it: +

+ {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''' +

MFA Enrollment {urgency.title()}

+

+ Dear {user_name}, +

+ {get_alert_box(f"Important: You have {days_remaining} days to set up multi-factor authentication for your account with {org_name}.", "warning", "⏰")} +

+ To maintain access to your account, please complete the following: +

+ + + + +
+

Required MFA Methods:

+

{mfa_methods}

+

Deadline:

+

{deadline_date}

+
+ {get_action_button(setup_link, "Set Up MFA Now", PRIMARY_COLOR)} +

+ If you do not set up MFA by the deadline, your account access will be restricted. +

+

+ If you have questions, please contact your organization administrator. +

+ ''' + 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''' +

Account Access Restricted

+

+ Dear {user_name}, +

+ {get_alert_box("Your account has been suspended because you did not set up multi-factor authentication within the required timeframe.", "danger", "🚫")} +

+ To restore access to your account with {org_name}, please complete the following: +

+ + + + +
+

Required MFA Methods:

+

{mfa_methods}

+

How to Restore Access:

+
    +
  1. Log in to your account (you will see a compliance enrollment screen)
  2. +
  3. Follow the prompts to set up an authenticator app or passkey
  4. +
  5. Once MFA is configured, your access will be restored
  6. +
+
+ {get_action_button(setup_link, "Set Up MFA Now", DANGER_COLOR)} +

+ Need help? Contact your organization administrator. +

+ ''' + 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''' +

You're Invited to Join {org_name}

+

+ You've been invited by {inviter_name} to join {org_name} on Secuird. +

+ + + + +
+

Invitation Details:

+

Organization: {org_name}

+

Role: {role}

+

This invitation expires in {expiry_days} days

+
+ {get_action_button(invite_link, "Accept Invitation", SUCCESS_COLOR)} +

+ If you did not expect this invitation, you can safely ignore this email. +

+ ''' + 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''' +

Verify Your Email Address

+

+ Hi {user_name}, +

+

+ Please verify your email address by clicking the button below: +

+ {get_action_button(verify_link, "Verify Email Address")} +

+ This link will expire in {expiry_hours} hours. +

+ {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") diff --git a/gatehouse_app/services/external_auth/linking.py b/gatehouse_app/services/external_auth/linking.py index e9fc5f4..f7e8c3a 100644 --- a/gatehouse_app/services/external_auth/linking.py +++ b/gatehouse_app/services/external_auth/linking.py @@ -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, ) diff --git a/gatehouse_app/services/notification_service.py b/gatehouse_app/services/notification_service.py index 068fe8f..af84b99 100644 --- a/gatehouse_app/services/notification_service.py +++ b/gatehouse_app/services/notification_service.py @@ -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() diff --git a/gatehouse_app/services/providers/mailgun_provider.py b/gatehouse_app/services/providers/mailgun_provider.py new file mode 100644 index 0000000..62df3f8 --- /dev/null +++ b/gatehouse_app/services/providers/mailgun_provider.py @@ -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 diff --git a/gatehouse_app/services/providers/sendgrid_provider.py b/gatehouse_app/services/providers/sendgrid_provider.py new file mode 100644 index 0000000..924d2fb --- /dev/null +++ b/gatehouse_app/services/providers/sendgrid_provider.py @@ -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 diff --git a/gatehouse_app/services/providers/smtp_provider.py b/gatehouse_app/services/providers/smtp_provider.py new file mode 100644 index 0000000..85c7b8e --- /dev/null +++ b/gatehouse_app/services/providers/smtp_provider.py @@ -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 diff --git a/gatehouse_app/services/totp_service.py b/gatehouse_app/services/totp_service.py index c667e3e..e733dce 100644 --- a/gatehouse_app/services/totp_service.py +++ b/gatehouse_app/services/totp_service.py @@ -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) diff --git a/gatehouse_app/services/webauthn_service.py b/gatehouse_app/services/webauthn_service.py index 9f953d3..59df130 100644 --- a/gatehouse_app/services/webauthn_service.py +++ b/gatehouse_app/services/webauthn_service.py @@ -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')) diff --git a/test_email.py b/test_email.py new file mode 100644 index 0000000..6061a5c --- /dev/null +++ b/test_email.py @@ -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 ", + ) + 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 ", + ) + 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 ", + ) + 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 ", + ) + 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 ", + ) + 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 ", + ) + 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 ", + ) + 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()