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:
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user