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
@@ -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