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
This commit is contained in:
2026-04-05 15:44:22 +00:00
parent f2386ed1da
commit 6325d60097
5 changed files with 127 additions and 50 deletions
+11 -7
View File
@@ -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.auth_service import AuthService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.services.notification_service import NotificationService 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.decorators import login_required
from gatehouse_app.utils.constants import AuditAction from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
@@ -32,14 +33,17 @@ def register():
verify_token = EmailVerificationToken.generate(user_id=user.id) verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080") app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}" verify_link = f"{app_url}/verify-email?token={verify_token.token}"
subject = "Verify your Secuird email address" email_body = build_email_verification_html(
body = ( user_name=user.full_name or user.email,
f"Hi {user.full_name or user.email},\n\n" verify_link=verify_link,
f"Welcome to Secuird! Please verify your email address by clicking the link below (valid for 24 hours):\n" expiry_hours=24,
f"{verify_link}\n\n" )
f"Secuird Security Team" 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: except Exception as exc:
logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}") logging.getLogger(__name__).warning(f"Failed to send verification email on register: {exc}")
+25 -21
View File
@@ -6,6 +6,11 @@ from gatehouse_app.extensions import limiter
from gatehouse_app.utils.response import api_response from gatehouse_app.utils.response import api_response
from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.notification_service import NotificationService 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__) _logger = logging.getLogger(__name__)
@@ -27,17 +32,16 @@ def forgot_password():
reset_token = PasswordResetToken.generate(user_id=user.id) reset_token = PasswordResetToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080") app_url = current_app.config.get("APP_URL", "http://localhost:8080")
reset_link = f"{app_url}/reset-password?token={reset_token.token}" 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( NotificationService._send_email_async(
to_address=user.email, to_address=user.email,
subject="Reset your Secuird password", subject="Reset your Secuird password",
body=( body=f"Reset your Secuird password: {reset_link}",
f"Hi {user.full_name or user.email},\n\n" html_body=email_body,
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"
),
) )
_logger.info(f"Password reset token generated for user {user.id}") _logger.info(f"Password reset token generated for user {user.id}")
except Exception as exc: except Exception as exc:
@@ -129,15 +133,16 @@ def resend_verification():
verify_token = EmailVerificationToken.generate(user_id=user.id) verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080") app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}" 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( NotificationService._send_email_async(
to_address=user.email, to_address=user.email,
subject="Verify your Secuird email address", subject="Verify your Secuird email address",
body=( body=f"Verify your Secuird email: {verify_link}",
f"Hi {user.full_name or user.email},\n\n" html_body=email_body,
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"
),
) )
_logger.info(f"Verification email sent for user {user.id}") _logger.info(f"Verification email sent for user {user.id}")
except Exception as exc: 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")) 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}" 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( NotificationService._send_email_async(
to_address=user.email, to_address=user.email,
subject="Activate your Secuird account", subject="Activate your Secuird account",
body=( body=f"Activate your Secuird account: {activate_link}",
f"Hi {user.full_name or user.email},\n\n" html_body=email_body,
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"
),
) )
_logger.info(f"Activation email re-sent to {user.id}") _logger.info(f"Activation email re-sent to {user.id}")
except Exception as exc: except Exception as exc:
+12 -6
View File
@@ -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.notification_service import NotificationService
from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.organization_service import OrganizationService 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 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") app_url = current_app.config.get("APP_URL", "http://localhost:8080")
invite_link = f"{app_url}/invite?token={invite.token}" 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( NotificationService._send_email_async(
to_address=email, to_address=email,
subject=f"You're invited to join {org.name} on Secuird", subject=f"You're invited to join {org.name} on Secuird",
body=( body=f"You've been invited to join {org.name} on Secuird. Open this link in your browser: {invite_link}",
f"You've been invited to join {org.name} on Secuird.\n\n" html_body=email_body,
f"Click the link below to accept the invitation (valid for 7 days):\n"
f"{invite_link}\n\n"
f"Secuird Security Team"
),
) )
logging.getLogger(__name__).info(f"[INVITE] Email queued for {email}") logging.getLogger(__name__).info(f"[INVITE] Email queued for {email}")
email_sent = True # async — assume queued successfully email_sent = True # async — assume queued successfully
+13 -8
View File
@@ -1,5 +1,5 @@
"""Organization member management endpoints.""" """Organization member management endpoints."""
from flask import g, request from flask import g, request, current_app
from marshmallow import ValidationError from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response 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.schemas.organization_schema import InviteMemberSchema, UpdateMemberRoleSchema
from gatehouse_app.services.organization_service import OrganizationService from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.user_service import UserService 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 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: if compliance and policy and compliance.deadline_at:
NotificationService.send_mfa_deadline_reminder(user, compliance, policy) NotificationService.send_mfa_deadline_reminder(user, compliance, policy)
else: else:
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
setup_link = f"{app_url}/settings/security"
html_body = f'''
<h2>Reminder: Set up multi-factor authentication</h2>
<p>Hi {user.full_name or user.email},</p>
<p>Your organization administrator has asked you to set up multi-factor authentication (MFA) on your Secuird account.</p>
<p>Please log in and configure MFA as soon as possible.</p>
<p><a href="{setup_link}">Set up MFA now</a></p>
'''
NotificationService._send_email_async( NotificationService._send_email_async(
to_address=user.email, to_address=user.email,
subject="Reminder: Set up multi-factor authentication", subject="Reminder: Set up multi-factor authentication",
body=( body=f"Set up MFA on Secuird: {setup_link}",
f"Hi {user.full_name or user.email},\n\n" html_body=html_body,
"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"
),
) )
return api_response(data={}, message="Reminder sent successfully") return api_response(data={}, message="Reminder sent successfully")
+66 -8
View File
@@ -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.models.user.user import User
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.email_provider import EmailMessage, EmailProviderFactory 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 from gatehouse_app.utils.constants import AuditAction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,17 +77,46 @@ class NotificationService:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
days_until_deadline = (deadline - now).days 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" subject = f"Action Required: MFA enrollment deadline in {days_until_deadline} days"
body = NotificationService._build_deadline_reminder_body( html_body = build_mfa_deadline_reminder_html(
user, compliance, org_policy, days_until_deadline 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 # Send the notification
NotificationService._send_email_async( NotificationService._send_email_async(
to_address=user.email, to_address=user.email,
subject=subject, subject=subject,
body=body, body=f"MFA enrollment deadline in {days_until_deadline} days: {setup_link}",
html_body=html_body,
) )
logger.info( logger.info(
f"Sent MFA deadline reminder to {user.email} " f"Sent MFA deadline reminder to {user.email} "
@@ -124,17 +157,42 @@ class NotificationService:
True if notification was sent successfully, False otherwise True if notification was sent successfully, False otherwise
""" """
try: 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" subject = "Account Access Restricted - MFA Enrollment Required"
body = NotificationService._build_suspension_body( html_body = build_mfa_suspension_html(
user, compliance, org_policy user_name=user.full_name or user.email,
org_name=org_name,
mfa_methods=mfa_methods,
setup_link=setup_link,
) )
# Send the notification # Send the notification
NotificationService._send_email_async( NotificationService._send_email_async(
to_address=user.email, to_address=user.email,
subject=subject, 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}") logger.info(f"Sent MFA suspension notification to {user.email}")
AuditService.log_action( AuditService.log_action(