Files
mail/src/app.py
T
libretech 2efca9415e
build / build (push) Failing after 7s
build / test (push) Successful in 16s
One secured SMTP path, optional login, sender and reply address, failing health (#3)
Co-authored-by: Michael Czechowski <mail@librete.ch>
2026-10-15 10:43:10 +02:00

275 lines
8.9 KiB
Python

from flask import Flask, request, jsonify
from prometheus_flask_exporter import PrometheusMetrics
import smtplib
from email.utils import formatdate, make_msgid, parseaddr
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
import os
import time
import html
import re
import logging
class FilterRemoveDate(logging.Filter):
# '192.168.0.102 - - [30/Jun/2024 01:14:03] "%s" %s %s' -> '192.168.0.102 - "%s" %s %s'
pattern: re.Pattern = re.compile(r' - \[.+?]')
def filter(self, record: logging.LogRecord) -> bool:
record.msg = self.pattern.sub('', record.msg)
return True
class FilterReplaceWerkzeug(logging.Filter):
# 'werkzeug:' -> 'app:flask:'
pattern: re.Pattern = re.compile(r'werkzeug:')
def filter(self, record: logging.LogRecord) -> bool:
record.msg = self.pattern.sub('app:flask:', record.msg)
return True
class FilterReplaceLowercaseI(logging.Filter):
# 'I:' -> 'i:'
pattern: re.Pattern = re.compile(r'I:')
def filter(self, record: logging.LogRecord) -> bool:
record.msg = self.pattern.sub('i:', record.msg)
return True
# Setup logger
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s.%(msecs)03dZ %(name)s:%(levelname).1s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger('app')
logger_werkzeug = logging.getLogger('werkzeug')
logger_werkzeug.addFilter(FilterRemoveDate())
logger_werkzeug.addFilter(FilterReplaceWerkzeug())
logger_werkzeug.addFilter(FilterReplaceLowercaseI())
app = Flask(__name__, static_folder=None)
# Wrap the Flask app with PrometheusMetrics
metrics = PrometheusMetrics(app, defaults_prefix="mail")
email_sent_counter = metrics.counter(
'mail_text_sent_total',
'Total number of text messages sent',
labels={'endpoint': lambda: request.endpoint}
)
pdf_sent_counter = metrics.counter(
'mail_pdf_sent_total',
'Total number of pdfs sent',
labels={'endpoint': lambda: request.endpoint}
)
# --- SMTP --------------------------------------------------------------------
# One connection path for every endpoint. SMTP_RELAY_SECURITY is "ssl" (TLS from the first byte, port 465), "starttls"
# (port 587 or 25) or "none" (a relay on a private network, e.g. Mailu's smtp container trusting its own subnet).
# Without it, port 465 means ssl and any other port starttls. The service logs in only when both SMTP_RELAY_USERNAME
# and SMTP_RELAY_PASSWORD are set. SMTP_FROM is the sender (default: SMTP_RELAY_USERNAME); SMTP_REPLY_TO, if set,
# is where replies go.
SECURITIES = ("ssl", "starttls", "none")
class SmtpConfigError(Exception):
pass
def smtp_settings():
host = os.environ.get('SMTP_RELAY_HOST', '')
port_text = os.environ.get('SMTP_RELAY_PORT', '')
if not host or not port_text.isdigit():
raise SmtpConfigError("SMTP_RELAY_HOST and a numeric SMTP_RELAY_PORT are required")
port = int(port_text)
security = os.environ.get('SMTP_RELAY_SECURITY') or ("ssl" if port == 465 else "starttls")
if security not in SECURITIES:
raise SmtpConfigError(f"SMTP_RELAY_SECURITY must be one of {', '.join(SECURITIES)}")
username = os.environ.get('SMTP_RELAY_USERNAME', '')
password = os.environ.get('SMTP_RELAY_PASSWORD', '')
sender = os.environ.get('SMTP_FROM') or username
if not parseaddr(sender)[1]:
raise SmtpConfigError("SMTP_FROM (or SMTP_RELAY_USERNAME) must be a mail address")
return {
'host': host, 'port': port, 'security': security, 'username': username, 'password': password,
'sender': sender, 'reply_to': os.environ.get('SMTP_REPLY_TO', '')
}
def open_smtp(settings, timeout=30):
"""Connects, secures the connection as configured and logs in if credentials are set. A failed STARTTLS is an error:
the credentials and the mail must not travel in the clear."""
if settings['security'] == "ssl":
smtp = smtplib.SMTP_SSL(settings['host'], settings['port'], timeout=timeout)
else:
smtp = smtplib.SMTP(settings['host'], settings['port'], timeout=timeout)
try:
smtp.ehlo()
if settings['security'] == "starttls":
smtp.starttls()
smtp.ehlo()
if settings['username'] and settings['password']:
smtp.login(settings['username'], settings['password'])
except Exception:
smtp.close()
raise
return smtp
def new_message(settings, to_email, subject):
msg = MIMEMultipart('mixed')
msg['From'] = settings['sender']
msg['To'] = to_email
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=False)
msg['Message-ID'] = make_msgid(domain=parseaddr(settings['sender'])[1].rpartition('@')[2] or None)
if settings['reply_to']:
msg['Reply-To'] = settings['reply_to']
return msg
def deliver(settings, to_email, msg):
smtp = open_smtp(settings)
try:
smtp.sendmail(parseaddr(settings['sender'])[1], [to_email], msg.as_string())
finally:
try:
smtp.quit()
except smtplib.SMTPException:
smtp.close()
@app.route('/v1')
@metrics.do_not_track()
def info():
today = time.strftime("%Y-%m-%d")
url_map = app.url_map
return f"<h1>mail v1.0.0 - {today}</h1><pre>" + html.escape(str(url_map), False) + "</pre>"
@app.route('/v1/send/message', methods=['POST'])
@email_sent_counter
def send_message():
logger = logging.getLogger('app:send-message')
data = request.json
subject = data.get('subject')
text_content = data.get('message')
to_email = data.get('to_email')
html_content = data.get('html')
logger.info(f"Sending text email to {to_email}")
logger.debug(f"Subject: {subject}")
try:
settings = smtp_settings()
msg = new_message(settings, to_email, subject)
# Create the multipart/alternative part
msgAlternative = MIMEMultipart('alternative')
msg.attach(msgAlternative)
# Attach plain text message first (lower priority)
msgAlternative.attach(MIMEText(text_content, 'plain', 'utf-8'))
# Attach HTML version if provided (higher priority)
if html_content:
logger.debug("HTML content included in email")
msgAlternative.attach(MIMEText(html_content, 'html', 'utf-8'))
logger.debug(f"Connecting to SMTP server {settings['host']}:{settings['port']} ({settings['security']})")
deliver(settings, to_email, msg)
logger.info("Email sent successfully")
return jsonify({"message": "Email sent successfully"}), 200
except Exception as e:
logger.error(f"Error sending email: {str(e)}")
return jsonify({"error": str(e)}), 500
@app.route('/v1/send/pdf', methods=['POST'])
@pdf_sent_counter
def send_pdf():
logger = logging.getLogger('app:send-pdf')
data = request.form
subject = data.get('subject')
text_content = data.get('message')
html_content = data.get('html')
to_email = data.get('to_email')
pdf_blob = request.files.get('pdf_blob')
logger.info(f"Sending PDF email to {to_email}")
logger.debug(f"Subject: {subject}")
try:
settings = smtp_settings()
msg = new_message(settings, to_email, subject)
# Create the multipart/alternative part for text and HTML
msgAlternative = MIMEMultipart('alternative')
msg.attach(msgAlternative)
# Attach plain text message first (lower priority)
msgAlternative.attach(MIMEText(text_content, 'plain', 'utf-8'))
# Attach HTML version if provided (higher priority)
if html_content:
logger.debug("HTML content included in email")
msgAlternative.attach(MIMEText(html_content, 'html', 'utf-8'))
# Attach PDF if provided
if pdf_blob:
logger.debug("Attaching PDF to email")
pdf_attachment = MIMEApplication(pdf_blob.read(), _subtype="pdf")
pdf_attachment.add_header('Content-Disposition', 'attachment', filename='document.pdf')
msg.attach(pdf_attachment)
else:
logger.warning("No PDF attachment provided")
logger.debug(f"Connecting to SMTP server {settings['host']}:{settings['port']} ({settings['security']})")
deliver(settings, to_email, msg)
logger.info("PDF email sent successfully")
return jsonify({"message": "Email sent successfully"}), 200
except Exception as e:
logger.error(f"Error sending PDF email: {str(e)}")
return jsonify({"error": str(e)}), 500
@app.route('/health', methods=['GET'])
def health_check():
"""503 unless the configured SMTP server answers and accepts the connection's security and login, so Docker's
healthcheck marks the container unhealthy when no mail can leave."""
logger = logging.getLogger('app:health-check')
health_status = {'status': 'ok', 'smtp_config': True, 'smtp_responding': False}
try:
settings = smtp_settings()
except SmtpConfigError as e:
logger.error(f"SMTP configuration incomplete: {e}")
health_status.update(status='error', smtp_config=False)
return jsonify(health_status), 503
try:
smtp = open_smtp(settings, timeout=5)
try:
smtp.noop()
health_status['smtp_responding'] = True
finally:
try:
smtp.quit()
except smtplib.SMTPException:
smtp.close()
except Exception as e:
logger.error(f"SMTP connection test failed: {str(e)}")
health_status['status'] = 'error'
logger.debug(f"Health check performed: {health_status}")
return jsonify(health_status), (200 if health_status['status'] == 'ok' else 503)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=2222, debug=False)