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