One secured SMTP path, optional login, sender and reply address, failing health (#3)
Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #3.
This commit is contained in:
1 parent
22e642e5bb
commit
2efca9415e
6 files changed
+251
-187
No files matched your search
+104
-69
@@ -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)
|
||||
Reference in new issue
Block a user