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"

mail v1.0.0 - {today}

" + html.escape(str(url_map), False) + "
" @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)