275 lines
8.9 KiB
Python
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)
|