From 6325d600976047d8bb0d0319a386b55eac65a0cb Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Sun, 5 Apr 2026 15:44:22 +0000 Subject: [PATCH] feat(email): use HTML templates for all transactional emails - Update org invite, password reset, email verification, and account activation emails to use HTML templates - Update MFA deadline reminder and suspension notifications to use HTML templates - Add html_body parameter to _send_email_async for rich email content --- gatehouse_app/api/v1/auth/core.py | 18 +++-- gatehouse_app/api/v1/auth/password.py | 46 ++++++------ gatehouse_app/api/v1/organizations/invites.py | 18 +++-- gatehouse_app/api/v1/organizations/members.py | 21 ++++-- .../services/notification_service.py | 74 +++++++++++++++++-- 5 files changed, 127 insertions(+), 50 deletions(-) diff --git a/gatehouse_app/api/v1/auth/core.py b/gatehouse_app/api/v1/auth/core.py index 3c0edc1..42f11c4 100644 --- a/gatehouse_app/api/v1/auth/core.py +++ b/gatehouse_app/api/v1/auth/core.py @@ -9,6 +9,7 @@ from gatehouse_app.schemas.auth_schema import RegisterSchema, LoginSchema from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.mfa_policy_service import MfaPolicyService from gatehouse_app.services.notification_service import NotificationService +from gatehouse_app.services.email_templates import build_email_verification_html from gatehouse_app.utils.decorators import login_required from gatehouse_app.utils.constants import AuditAction from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError @@ -32,14 +33,17 @@ 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 Secuird email address" - body = ( - f"Hi {user.full_name or user.email},\n\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"Secuird Security Team" + email_body = build_email_verification_html( + user_name=user.full_name or user.email, + verify_link=verify_link, + expiry_hours=24, + ) + NotificationService._send_email_async( + to_address=user.email, + subject="Verify your Secuird email address", + body=f"Verify your Secuird email: {verify_link}", + html_body=email_body, ) - NotificationService._send_email_async(to_address=user.email, subject=subject, body=body) except Exception as exc: logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}") diff --git a/gatehouse_app/api/v1/auth/password.py b/gatehouse_app/api/v1/auth/password.py index 0c34a17..9ca6b2b 100644 --- a/gatehouse_app/api/v1/auth/password.py +++ b/gatehouse_app/api/v1/auth/password.py @@ -6,6 +6,11 @@ from gatehouse_app.extensions import limiter from gatehouse_app.utils.response import api_response from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.notification_service import NotificationService +from gatehouse_app.services.email_templates import ( + build_password_reset_html, + build_email_verification_html, + build_account_activation_html, +) _logger = logging.getLogger(__name__) @@ -27,17 +32,16 @@ def forgot_password(): reset_token = PasswordResetToken.generate(user_id=user.id) app_url = current_app.config.get("APP_URL", "http://localhost:8080") reset_link = f"{app_url}/reset-password?token={reset_token.token}" + email_body = build_password_reset_html( + user_name=user.full_name or user.email, + reset_link=reset_link, + expiry_hours=2, + ) NotificationService._send_email_async( to_address=user.email, subject="Reset your Secuird password", - body=( - f"Hi {user.full_name or user.email},\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"Secuird Security Team" - ), + body=f"Reset your Secuird password: {reset_link}", + html_body=email_body, ) _logger.info(f"Password reset token generated for user {user.id}") except Exception as exc: @@ -129,15 +133,16 @@ def resend_verification(): 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}" + email_body = build_email_verification_html( + user_name=user.full_name or user.email, + verify_link=verify_link, + expiry_hours=24, + ) NotificationService._send_email_async( to_address=user.email, 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"Secuird Security Team" - ), + body=f"Verify your Secuird email: {verify_link}", + html_body=email_body, ) _logger.info(f"Verification email sent for user {user.id}") except Exception as exc: @@ -200,16 +205,15 @@ def resend_activation(): app_url = current_app.config.get("APP_URL", current_app.config.get("FRONTEND_URL", "http://localhost:8080")) activate_link = f"{app_url}/activate?code={code}" + email_body = build_account_activation_html( + user_name=user.full_name or user.email, + activation_link=activate_link, + ) NotificationService._send_email_async( to_address=user.email, subject="Activate your Secuird account", - body=( - f"Hi {user.full_name or user.email},\n\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"Secuird Security Team" - ), + body=f"Activate your Secuird account: {activate_link}", + html_body=email_body, ) _logger.info(f"Activation email re-sent to {user.id}") except Exception as exc: diff --git a/gatehouse_app/api/v1/organizations/invites.py b/gatehouse_app/api/v1/organizations/invites.py index f4ee1fd..a98ca42 100644 --- a/gatehouse_app/api/v1/organizations/invites.py +++ b/gatehouse_app/api/v1/organizations/invites.py @@ -7,6 +7,7 @@ from gatehouse_app.utils.decorators import login_required, require_admin from gatehouse_app.services.notification_service import NotificationService from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.organization_service import OrganizationService +from gatehouse_app.services.email_templates import build_org_invite_html from gatehouse_app.utils.constants import OrganizationRole @@ -37,15 +38,20 @@ def create_org_invite(org_id): app_url = current_app.config.get("APP_URL", "http://localhost:8080") invite_link = f"{app_url}/invite?token={invite.token}" + inviter_name = g.current_user.full_name or g.current_user.email + email_body = build_org_invite_html( + inviter_name=inviter_name, + org_name=org.name, + invite_link=invite_link, + role=role, + expiry_days=7, + ) + NotificationService._send_email_async( to_address=email, subject=f"You're invited to join {org.name} on Secuird", - body=( - 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"Secuird Security Team" - ), + body=f"You've been invited to join {org.name} on Secuird. Open this link in your browser: {invite_link}", + html_body=email_body, ) logging.getLogger(__name__).info(f"[INVITE] Email queued for {email}") email_sent = True # async — assume queued successfully diff --git a/gatehouse_app/api/v1/organizations/members.py b/gatehouse_app/api/v1/organizations/members.py index 3198237..8b112d7 100644 --- a/gatehouse_app/api/v1/organizations/members.py +++ b/gatehouse_app/api/v1/organizations/members.py @@ -1,5 +1,5 @@ """Organization member management endpoints.""" -from flask import g, request +from flask import g, request, current_app from marshmallow import ValidationError from gatehouse_app.api.v1 import api_v1_bp from gatehouse_app.utils.response import api_response @@ -7,6 +7,7 @@ from gatehouse_app.utils.decorators import login_required, require_admin, full_a from gatehouse_app.schemas.organization_schema import InviteMemberSchema, UpdateMemberRoleSchema from gatehouse_app.services.organization_service import OrganizationService from gatehouse_app.services.user_service import UserService +from gatehouse_app.services.email_templates import build_mfa_setup_reminder_html from gatehouse_app.utils.constants import OrganizationRole @@ -161,16 +162,20 @@ def send_mfa_reminder(org_id, user_id): if compliance and policy and compliance.deadline_at: NotificationService.send_mfa_deadline_reminder(user, compliance, policy) else: + app_url = current_app.config.get("APP_URL", "http://localhost:8080") + setup_link = f"{app_url}/settings/security" + html_body = f''' +

Reminder: Set up multi-factor authentication

+

Hi {user.full_name or user.email},

+

Your organization administrator has asked you to set up multi-factor authentication (MFA) on your Secuird account.

+

Please log in and configure MFA as soon as possible.

+

Set up MFA now

+ ''' NotificationService._send_email_async( to_address=user.email, subject="Reminder: Set up multi-factor authentication", - 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 Secuird account.\n\n" - "Please log in and configure MFA as soon as possible.\n\n" - "Secuird Security Team" - ), + body=f"Set up MFA on Secuird: {setup_link}", + html_body=html_body, ) return api_response(data={}, message="Reminder sent successfully") diff --git a/gatehouse_app/services/notification_service.py b/gatehouse_app/services/notification_service.py index af84b99..fb9639a 100644 --- a/gatehouse_app/services/notification_service.py +++ b/gatehouse_app/services/notification_service.py @@ -25,6 +25,10 @@ from gatehouse_app.models.security.organization_security_policy import Organizat 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.services.email_templates import ( + build_mfa_deadline_reminder_html, + build_mfa_suspension_html, +) from gatehouse_app.utils.constants import AuditAction logger = logging.getLogger(__name__) @@ -73,17 +77,46 @@ class NotificationService: now = datetime.now(timezone.utc) days_until_deadline = (deadline - now).days - # Build notification content + # Get organization name + org_name = compliance.organization_id + from gatehouse_app.models.organization.organization import Organization + org = Organization.query.get(compliance.organization_id) + if org: + org_name = org.name + + # Build required MFA methods string + from gatehouse_app.utils.constants import MfaPolicyMode + mfa_methods = "Multi-factor authentication" + mode = org_policy.mfa_policy_mode + if mode == MfaPolicyMode.REQUIRE_TOTP: + mfa_methods = "Authenticator app (TOTP)" + elif mode == MfaPolicyMode.REQUIRE_WEBAUTHN: + mfa_methods = "Passkey (WebAuthn)" + elif mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN: + mfa_methods = "Authenticator app (TOTP) OR Passkey (WebAuthn)" + + deadline_date = compliance.deadline_at.strftime('%Y-%m-%d %H:%M UTC') if compliance.deadline_at else 'Not set' + + # Build HTML email + app_url = current_app.config.get("APP_URL", "http://localhost:8080") + setup_link = f"{app_url}/settings/security" + subject = f"Action Required: MFA enrollment deadline in {days_until_deadline} days" - body = NotificationService._build_deadline_reminder_body( - user, compliance, org_policy, days_until_deadline + html_body = build_mfa_deadline_reminder_html( + user_name=user.full_name or user.email, + org_name=org_name, + days_remaining=days_until_deadline, + deadline_date=deadline_date, + mfa_methods=mfa_methods, + setup_link=setup_link, ) # Send the notification NotificationService._send_email_async( to_address=user.email, subject=subject, - body=body, + body=f"MFA enrollment deadline in {days_until_deadline} days: {setup_link}", + html_body=html_body, ) logger.info( f"Sent MFA deadline reminder to {user.email} " @@ -124,17 +157,42 @@ class NotificationService: True if notification was sent successfully, False otherwise """ try: - # Build notification content + # Get organization name + org_name = compliance.organization_id + from gatehouse_app.models.organization.organization import Organization + org = Organization.query.get(compliance.organization_id) + if org: + org_name = org.name + + # Build required MFA methods string + from gatehouse_app.utils.constants import MfaPolicyMode + mfa_methods = "Multi-factor authentication" + mode = org_policy.mfa_policy_mode + if mode == MfaPolicyMode.REQUIRE_TOTP: + mfa_methods = "Authenticator app (TOTP)" + elif mode == MfaPolicyMode.REQUIRE_WEBAUTHN: + mfa_methods = "Passkey (WebAuthn)" + elif mode == MfaPolicyMode.REQUIRE_TOTP_OR_WEBAUTHN: + mfa_methods = "Authenticator app (TOTP) OR Passkey (WebAuthn)" + + # Build HTML email + app_url = current_app.config.get("APP_URL", "http://localhost:8080") + setup_link = f"{app_url}/settings/security" + subject = "Account Access Restricted - MFA Enrollment Required" - body = NotificationService._build_suspension_body( - user, compliance, org_policy + html_body = build_mfa_suspension_html( + user_name=user.full_name or user.email, + org_name=org_name, + mfa_methods=mfa_methods, + setup_link=setup_link, ) # Send the notification NotificationService._send_email_async( to_address=user.email, subject=subject, - body=body, + body=f"Your account has been suspended. Set up MFA to restore access: {setup_link}", + html_body=html_body, ) logger.info(f"Sent MFA suspension notification to {user.email}") AuditService.log_action(