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:
2026-04-04 16:55:00 +10:30
parent d90a06437e
commit 41bbdb4bef
17 changed files with 1068 additions and 76 deletions
+18 -49
View File
@@ -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()