One secured SMTP path, optional login, sender and reply address, failing health (#3)
build / build (push) Failing after 7s
build / test (push) Successful in 16s

Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #3.
This commit is contained in:
libretech authored and Michael Czechowski committed 2026-10-15 10:43:10 +02:00
1 parent 22e642e5bb
commit 2efca9415e
6 files changed
+251 -187

No files matched your search

+104 -69
View File
@@ -1,6 +1,7 @@
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
@@ -66,6 +67,82 @@ pdf_sent_counter = metrics.counter(
)
# --- 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():
@@ -87,18 +164,9 @@ def send_message():
logger.info(f"Sending text email to {to_email}")
logger.debug(f"Subject: {subject}")
# SMTP Configuration
smtp_server = os.environ.get('SMTP_RELAY_HOST')
smtp_port = int(os.environ.get('SMTP_RELAY_PORT'))
smtp_username = os.environ.get('SMTP_RELAY_USERNAME')
smtp_password = os.environ.get('SMTP_RELAY_PASSWORD')
try:
# Create a message
msg = MIMEMultipart('mixed')
msg['From'] = smtp_username
msg['To'] = to_email
msg['Subject'] = subject
settings = smtp_settings()
msg = new_message(settings, to_email, subject)
# Create the multipart/alternative part
msgAlternative = MIMEMultipart('alternative')
@@ -112,12 +180,8 @@ def send_message():
logger.debug("HTML content included in email")
msgAlternative.attach(MIMEText(html_content, 'html', 'utf-8'))
# Send email
logger.debug(f"Connecting to SMTP server {smtp_server}:{smtp_port}")
with smtplib.SMTP_SSL(smtp_server, smtp_port) as smtp:
smtp.login(smtp_username, smtp_password)
smtp.sendmail(smtp_username, to_email, msg.as_string())
smtp.quit()
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
@@ -140,18 +204,9 @@ def send_pdf():
logger.info(f"Sending PDF email to {to_email}")
logger.debug(f"Subject: {subject}")
# SMTP Configuration
smtp_server = os.environ.get('SMTP_RELAY_HOST')
smtp_port = int(os.environ.get('SMTP_RELAY_PORT'))
smtp_username = os.environ.get('SMTP_RELAY_USERNAME')
smtp_password = os.environ.get('SMTP_RELAY_PASSWORD')
try:
# Create a message with mixed content (attachments + text)
msg = MIMEMultipart('mixed')
msg['From'] = smtp_username
msg['To'] = to_email
msg['Subject'] = subject
settings = smtp_settings()
msg = new_message(settings, to_email, subject)
# Create the multipart/alternative part for text and HTML
msgAlternative = MIMEMultipart('alternative')
@@ -174,19 +229,8 @@ def send_pdf():
else:
logger.warning("No PDF attachment provided")
# Send email
logger.debug(f"Connecting to SMTP server {smtp_server}:{smtp_port}")
with smtplib.SMTP(smtp_server, smtp_port) as smtp:
logger.debug("SMTP connection established")
try:
smtp.starttls()
logger.debug("STARTTLS successful")
except Exception as e:
logger.error(f"STARTTLS failed: {str(e)}")
smtp.login(smtp_username, smtp_password)
smtp.sendmail(smtp_username, to_email, msg.as_string())
logger.debug("Email sent via SMTP")
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
@@ -197,43 +241,34 @@ def send_pdf():
@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'}
health_status = {'status': 'ok', 'smtp_config': True, 'smtp_responding': False}
# Check SMTP configuration
smtp_server = os.environ.get('SMTP_RELAY_HOST')
smtp_port = os.environ.get('SMTP_RELAY_PORT')
smtp_username = os.environ.get('SMTP_RELAY_USERNAME')
if not all([smtp_server, smtp_port, smtp_username]):
logger.error("SMTP configuration incomplete")
health_status['smtp_config'] = False
health_status['status'] = 'error'
else:
health_status['smtp_config'] = True
# Try to connect to SMTP server
try:
smtp_port_int = int(smtp_port)
use_ssl = smtp_port_int == 465
if use_ssl:
with smtplib.SMTP_SSL(smtp_server, smtp_port_int, timeout=5) as smtp:
smtp.ehlo()
health_status['smtp_responding'] = True
else:
with smtplib.SMTP(smtp_server, smtp_port_int, timeout=5) as smtp:
smtp.ehlo()
health_status['smtp_responding'] = True
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['smtp_responding'] = False
health_status['status'] = 'error'
logger.debug(f"Health check performed: {health_status}")
return jsonify(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)