Add Mailgun API support for PROD email notifications

Mailgun API is used when MAILGUN_API_KEY and MAILGUN_DOMAIN are set,
with SMTP as fallback for PPR. Also fixes A2→A3 batch subject line
that was rendering Jinja2 syntax literally instead of substituting values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nickviljoen 2026-03-12 14:39:16 +02:00
parent 96b33fa084
commit dc779724fc
2 changed files with 101 additions and 55 deletions

View file

@ -80,11 +80,15 @@ retry:
notifications:
enabled: true
smtp:
server: ${SMTP_SERVER}
port: ${SMTP_PORT}
user: ${SMTP_USER}
password: ${SMTP_PASSWORD}
sender_email: ${SENDER_EMAIL}
server: ${SMTP_SERVER:-}
port: ${SMTP_PORT:-587}
user: ${SMTP_USER:-}
password: ${SMTP_PASSWORD:-}
sender_email: ${SENDER_EMAIL:-}
mailgun:
api_key: ${MAILGUN_API_KEY:-}
domain: ${MAILGUN_DOMAIN:-}
sender_email: ${MAILGUN_SENDER_EMAIL:-}
recipients:
success:
- ${REPORT_EMAILS}

View file

@ -18,7 +18,7 @@ class Notifier:
self.config = config
self.enabled = config['notifications']['enabled']
# SMTP configuration (preferred method)
# SMTP configuration
smtp_config = config['notifications'].get('smtp', {})
self.smtp_server = smtp_config.get('server')
self.smtp_port = smtp_config.get('port', 587)
@ -26,6 +26,12 @@ class Notifier:
self.smtp_password = smtp_config.get('password')
self.sender_email = smtp_config.get('sender_email')
# Mailgun API configuration (preferred over SMTP when configured)
mailgun_config = config['notifications'].get('mailgun', {})
self.mailgun_api_key = mailgun_config.get('api_key')
self.mailgun_domain = mailgun_config.get('domain')
self.mailgun_sender = mailgun_config.get('sender_email') or self.sender_email
self.recipients = config['notifications']['recipients']
self.webhook_config = config.get('webhooks', {})
@ -43,8 +49,8 @@ class Notifier:
logger.info("Notifications disabled, skipping email")
return
if not self.smtp_server or not self.smtp_user:
logger.warning("SMTP not configured, skipping email")
if not self.mailgun_api_key and (not self.smtp_server or not self.smtp_user):
logger.warning("Neither Mailgun API nor SMTP configured, skipping email")
return
try:
@ -111,7 +117,7 @@ class Notifier:
"""
},
'a2_to_a3_batch_complete': {
'subject': "A2→A3 Batch Upload Complete - {{ successful_count }}/{{ total_files }} Successful",
'subject': "A2→A3 Batch Upload Complete - {successful_count}/{total_files} Successful",
'html': """
<div style="font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto;">
<div style="background-color: {% if failed_count == 0 %}#28a745{% else %}#ff9800{% endif %}; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0;">
@ -978,59 +984,95 @@ class Notifier:
html_content = jinja_template.render(data)
subject = template['subject'].format(**data)
# 2. Create MIME message
if attachments:
# Use MIMEMultipart for attachments
message = MIMEMultipart()
message['From'] = self.sender_email
message['To'] = ", ".join(recipients) if isinstance(recipients, list) else recipients
message['Subject'] = subject
# Attach HTML body
message.attach(MIMEText(html_content, "html"))
# Attach files
from email.mime.base import MIMEBase
from email import encoders
import os
for file_path in attachments:
try:
if os.path.exists(file_path):
with open(file_path, "rb") as attachment:
part = MIMEBase("application", "octet-stream")
part.set_payload(attachment.read())
encoders.encode_base64(part)
filename = os.path.basename(file_path)
part.add_header(
"Content-Disposition",
f"attachment; filename= {filename}",
)
message.attach(part)
logger.info("Attached file: {}".format(filename))
else:
logger.warning("Attachment not found: {}".format(file_path))
except Exception as e:
logger.error("Failed to attach file {}: {}".format(file_path, str(e)))
else:
# Use standard MIMEText for simple emails
message = MIMEText(html_content, "html")
message['From'] = self.sender_email
message['To'] = ", ".join(recipients) if isinstance(recipients, list) else recipients
message['Subject'] = subject
# 2. Send via Mailgun API or SMTP
recipient_list = recipients if isinstance(recipients, list) else [recipients]
# 3. Send via SMTP
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
server.starttls()
server.login(self.smtp_user, self.smtp_password)
server.send_message(message)
if self.mailgun_api_key and self.mailgun_domain:
self._send_via_mailgun_api(recipient_list, subject, html_content, attachments)
else:
self._send_via_smtp(recipient_list, subject, html_content, attachments)
logger.info("Email sent to {} (Template: {})".format(recipients, template_name))
except Exception as e:
logger.error("Failed to send email: {}".format(str(e)))
def _send_via_mailgun_api(self, recipient_list, subject, html_content, attachments=None):
"""Send email via Mailgun REST API"""
import os
url = "https://api.mailgun.net/v3/{}/messages".format(self.mailgun_domain)
data = {
"from": self.mailgun_sender,
"to": recipient_list,
"subject": subject,
"html": html_content,
}
files = []
try:
if attachments:
for file_path in attachments:
if os.path.exists(file_path):
files.append(("attachment", (os.path.basename(file_path), open(file_path, "rb"))))
logger.info("Attaching file: {}".format(os.path.basename(file_path)))
else:
logger.warning("Attachment not found: {}".format(file_path))
response = requests.post(
url,
auth=("api", self.mailgun_api_key),
data=data,
files=files if files else None,
)
response.raise_for_status()
logger.info("Mailgun API response: {}".format(response.json()))
finally:
for _, file_tuple in files:
file_tuple[1].close()
def _send_via_smtp(self, recipient_list, subject, html_content, attachments=None):
"""Send email via SMTP"""
import os
from email.mime.base import MIMEBase
from email import encoders
if attachments:
message = MIMEMultipart()
message['From'] = self.sender_email
message['To'] = ", ".join(recipient_list)
message['Subject'] = subject
message.attach(MIMEText(html_content, "html"))
for file_path in attachments:
try:
if os.path.exists(file_path):
with open(file_path, "rb") as attachment:
part = MIMEBase("application", "octet-stream")
part.set_payload(attachment.read())
encoders.encode_base64(part)
filename = os.path.basename(file_path)
part.add_header(
"Content-Disposition",
"attachment; filename= {}".format(filename),
)
message.attach(part)
logger.info("Attached file: {}".format(filename))
else:
logger.warning("Attachment not found: {}".format(file_path))
except Exception as e:
logger.error("Failed to attach file {}: {}".format(file_path, str(e)))
else:
message = MIMEText(html_content, "html")
message['From'] = self.sender_email
message['To'] = ", ".join(recipient_list)
message['Subject'] = subject
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
server.starttls()
server.login(self.smtp_user, self.smtp_password)
server.send_message(message)
def send_webhook(self, url, payload):
"""
url: Webhook URL