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:
@@ -0,0 +1,83 @@
|
||||
"""Mailgun email provider implementation."""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
from gatehouse_app.services.email_provider import EmailMessage, EmailProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MailgunEmailProvider(EmailProvider):
|
||||
"""Mailgun API-based email provider implementation."""
|
||||
|
||||
# Configuration keys
|
||||
MAILGUN_API_KEY = "MAILGUN_API_KEY"
|
||||
MAILGUN_DOMAIN = "MAILGUN_DOMAIN"
|
||||
MAILGUN_API_URL = "MAILGUN_API_URL"
|
||||
FROM_ADDRESS = "FROM_ADDRESS"
|
||||
|
||||
DEFAULT_API_URL = "https://api.mailgun.net/v3"
|
||||
|
||||
def send(self, message: EmailMessage) -> bool:
|
||||
"""Send an email via Mailgun API.
|
||||
|
||||
Args:
|
||||
message: EmailMessage instance containing email details
|
||||
|
||||
Returns:
|
||||
bool: True if email was sent successfully, False otherwise
|
||||
"""
|
||||
api_key = current_app.config.get(self.MAILGUN_API_KEY)
|
||||
domain = current_app.config.get(self.MAILGUN_DOMAIN)
|
||||
api_url = current_app.config.get(self.MAILGUN_API_URL, self.DEFAULT_API_URL)
|
||||
default_from = current_app.config.get(self.FROM_ADDRESS)
|
||||
|
||||
missing = [k for k, v in [("MAILGUN_API_KEY", api_key), ("MAILGUN_DOMAIN", domain)] if not v]
|
||||
if missing:
|
||||
logger.error(
|
||||
f"[MAILGUN] Cannot send — missing config: {', '.join(missing)}. "
|
||||
f"Would have sent to: {message.to} | Subject: {message.subject}"
|
||||
)
|
||||
return False
|
||||
|
||||
from_address = message.from_address or default_from
|
||||
if not from_address:
|
||||
logger.error(
|
||||
f"[MAILGUN] Cannot send — missing FROM_ADDRESS. "
|
||||
f"Would have sent to: {message.to} | Subject: {message.subject}"
|
||||
)
|
||||
return False
|
||||
|
||||
url = f"{api_url}/{domain}/messages"
|
||||
|
||||
data = {
|
||||
"to": message.to,
|
||||
"subject": message.subject,
|
||||
"text": message.body,
|
||||
"from": from_address,
|
||||
}
|
||||
if message.html_body:
|
||||
data["html"] = message.html_body
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
auth=("api", api_key),
|
||||
data=data,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"[MAILGUN] Sent to {message.to} | Subject: {message.subject}")
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"[MAILGUN] Failed to send to {message.to}: from {from_address}"
|
||||
f"status={response.status_code} body={response.text}"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MAILGUN] Exception while sending to {message.to}: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,94 @@
|
||||
"""SendGrid email provider implementation."""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
from gatehouse_app.services.email_provider import EmailMessage, EmailProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SendGridEmailProvider(EmailProvider):
|
||||
"""SendGrid API-based email provider implementation."""
|
||||
|
||||
# Configuration keys
|
||||
SENDGRID_API_KEY = "SENDGRID_API_KEY"
|
||||
SENDGRID_FROM_EMAIL = "SENDGRID_FROM_EMAIL"
|
||||
FROM_ADDRESS = "FROM_ADDRESS"
|
||||
|
||||
API_URL = "https://api.sendgrid.com/v3/mail/send"
|
||||
|
||||
def send(self, message: EmailMessage) -> bool:
|
||||
"""Send an email via SendGrid API.
|
||||
|
||||
Args:
|
||||
message: EmailMessage instance containing email details
|
||||
|
||||
Returns:
|
||||
bool: True if email was sent successfully, False otherwise
|
||||
"""
|
||||
api_key = current_app.config.get(self.SENDGRID_API_KEY)
|
||||
default_from = current_app.config.get(self.SENDGRID_FROM_EMAIL)
|
||||
fallback_from = current_app.config.get(self.FROM_ADDRESS)
|
||||
|
||||
if not api_key:
|
||||
logger.error(
|
||||
f"[SENDGRID] Cannot send — missing SENDGRID_API_KEY config. "
|
||||
f"Would have sent to: {message.to} | Subject: {message.subject}"
|
||||
)
|
||||
return False
|
||||
|
||||
from_address = message.from_address or default_from or fallback_from
|
||||
if not from_address:
|
||||
logger.error(
|
||||
f"[SENDGRID] Cannot send — missing from address (SENDGRID_FROM_EMAIL or FROM_ADDRESS). "
|
||||
f"Would have sent to: {message.to} | Subject: {message.subject}"
|
||||
)
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"personalizations": [
|
||||
{
|
||||
"to": [{"email": message.to}]
|
||||
}
|
||||
],
|
||||
"from": {"email": from_address},
|
||||
"subject": message.subject,
|
||||
"content": [
|
||||
{
|
||||
"type": "text/plain",
|
||||
"value": message.body
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if message.html_body:
|
||||
payload["content"].append({
|
||||
"type": "text/html",
|
||||
"value": message.html_body
|
||||
})
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.API_URL,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 202:
|
||||
logger.info(f"[SENDGRID] Sent to {message.to} | Subject: {message.subject}")
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"[SENDGRID] Failed to send to {message.to}: "
|
||||
f"status={response.status_code} body={response.text}"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SENDGRID] Exception while sending to {message.to}: {e}")
|
||||
return False
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user