From c238d7756207e8e176cb3f8bfb395e44543a0f7d Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Wed, 29 Apr 2026 17:48:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20extract=20mail=20from=20mp/mail=20?= =?UTF-8?q?=E2=80=94=20initial=20libreshop/mail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source moved verbatim from mp/mail/ on 2026-04-29; mp was the first concrete adapter consuming the libreshop toolkit. Builds and publishes git.librete.ch/libreshop/mail on every main / v* push via the standard .gitea/workflows/build.yml shared across libreshop components. --- .dockerignore | 4 + .gitea/workflows/build.yml | 55 +++++++++ .gitignore | 15 +++ CHANGELOG.md | 8 ++ Dockerfile | 16 +++ README.md | 25 ++++ docker-entrypoint.sh | 7 ++ requirements.txt | 10 ++ src/app.py | 239 +++++++++++++++++++++++++++++++++++++ src/tests/test_app.py | 92 ++++++++++++++ src/tests/test_starttls.py | 44 +++++++ 11 files changed, 515 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-entrypoint.sh create mode 100644 requirements.txt create mode 100644 src/app.py create mode 100644 src/tests/test_app.py create mode 100644 src/tests/test_starttls.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..de4ddf7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.dockerignore +*Dockerfile +*.log +README.md diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..53fc905 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,55 @@ +name: Build and publish + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +# Required secrets: +# REGISTRY git.librete.ch +# REGISTRY_USER libretech-bot +# REGISTRY_PASS bot PAT (write:package; bot is in libreshop Owners team) +# Required variable: +# PUBLISH_ENABLED "true" to actually push (off = build-only on PRs) +# +# Image: git.librete.ch/libreshop/mail +# main pushes → :main + :sha- +# tag pushes → : + :latest + +jobs: + build: + runs-on: ubuntu-latest + container: + image: git.librete.ch/libretech/runner-image:v1 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Login (only when publishing) + if: ${{ vars.PUBLISH_ENABLED == 'true' }} + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASS }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.REGISTRY }}/libreshop/mail + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,format=short + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }} + + - uses: docker/build-push-action@v6 + with: + context: . + push: ${{ vars.PUBLISH_ENABLED == 'true' && github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45a6c34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.venv +# libreshop additions +.env +.env.local +.env.*.local +node_modules/ +.nuxt/ +.output/ +.cache/ +.parcel-cache/ +dist/ +build/ +coverage/ +logs/ +tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..47c1cf1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to libreshop/mail are documented here. + +## Unreleased + +- Extracted from `mp/mail/` (2026-04-29). The component history before + the extraction lives in the `muellerprints` repository. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d498031 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.13.1-alpine3.20 + +WORKDIR /app + +COPY ./requirements.txt requirements.txt + +RUN pip install -r requirements.txt + +COPY ./src /app/src + +COPY ./docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +ENTRYPOINT ["/app/docker-entrypoint.sh"] + +EXPOSE 2222 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd15f85 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# libreshop/mail + +Python microservice for transactional shop mail. + +Part of the [libreshop](https://git.librete.ch/libreshop) toolkit. Image +published at `git.librete.ch/libreshop/mail` on every push to `main` +and on `v*` tags. + +## Source + +This repo was extracted from `mp/mail/` on 2026-04-29; mp was the +first concrete adapter consuming the toolkit. mp's `compose.yml` now +pulls `git.librete.ch/libreshop/mail:` instead of building locally. + +## Build locally + +``` +docker build -t libreshop/mail:dev . +``` + +## Adapter contract + +See `docker-entrypoint.sh` and `Dockerfile` for the runtime surface. +Adapters configure the component via env vars and bind-mounted volumes; +do not patch the running container or rely on internal paths. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..83e5240 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +if [ "$ENVIRONMENT" = "production" ]; then + exec gunicorn -w 4 -b 0.0.0.0:2222 src.app:app +else + exec python3 src/app.py +fi diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f39d63e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +blinker==1.6.2 +click==8.1.7 +Flask==2.3.3 +gunicorn==21.2.0 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +prometheus-client==0.19.0 +prometheus-flask-exporter==0.23.0 +Werkzeug==2.3.7 diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..920c5e0 --- /dev/null +++ b/src/app.py @@ -0,0 +1,239 @@ +from flask import Flask, request, jsonify +from prometheus_flask_exporter import PrometheusMetrics +import smtplib +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} +) + + +@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}") + + # 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 + + # 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')) + + # 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.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}") + + # 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 + + # 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") + + # 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.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(): + logger = logging.getLogger('app:health-check') + health_status = {'status': 'ok'} + + # 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 + + 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) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=2222, debug=False) diff --git a/src/tests/test_app.py b/src/tests/test_app.py new file mode 100644 index 0000000..0fa027c --- /dev/null +++ b/src/tests/test_app.py @@ -0,0 +1,92 @@ +import unittest +from flask import Flask +from flask.testing import FlaskClient +from unittest.mock import patch, MagicMock +from app import app + +class TestApp(unittest.TestCase): + def setUp(self): + app.testing = True + self.app = app.test_client() + + def test_info_endpoint(self): + response = self.app.get('/v1') + self.assertEqual(response.status_code, 200) + self.assertIn(b"mail v1.0.0", response.data) + + @patch('smtplib.SMTP') + def test_send_message_endpoint_success(self, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + with app.app_context(): + response = self.app.post('/v1/send/message', json={ + 'subject': 'Test Subject', + 'message': 'Test Message', + 'to_email': 'test@example.com' + }) + + self.assertEqual(response.status_code, 200) + mock_smtp_instance.starttls.assert_called_once() + mock_smtp_instance.login.assert_called_once() + mock_smtp_instance.sendmail.assert_called_once() + mock_smtp_instance.quit.assert_called_once() + + @patch('smtplib.SMTP') + def test_send_message_endpoint_failure(self, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp_instance.login.side_effect = smtplib.SMTPAuthenticationError("Authentication failed") + mock_smtp.return_value = mock_smtp_instance + + with app.app_context(): + response = self.app.post('/v1/send/message', json={ + 'subject': 'Test Subject', + 'message': 'Test Message', + 'to_email': 'test@example.com' + }) + + self.assertEqual(response.status_code, 500) + mock_smtp_instance.starttls.assert_called_once() + mock_smtp_instance.login.assert_called_once() + mock_smtp_instance.sendmail.assert_not_called() + mock_smtp_instance.quit.assert_called_once() + + @patch('smtplib.SMTP') + def test_send_pdf_endpoint_success(self, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + with app.app_context(): + response = self.app.post('/v1/send/pdf', data={ + 'subject': 'Test Subject', + 'message': 'Test Message', + 'to_email': 'test@example.com', + }, content_type='multipart/form-data', files={'pdf_blob': ('document.pdf', b'PDF_CONTENT')}) + + self.assertEqual(response.status_code, 200) + mock_smtp_instance.starttls.assert_called_once() + mock_smtp_instance.login.assert_called_once() + mock_smtp_instance.sendmail.assert_called_once() + mock_smtp_instance.quit.assert_called_once() + + @patch('smtplib.SMTP') + def test_send_pdf_endpoint_failure(self, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp_instance.login.side_effect = smtplib.SMTPAuthenticationError("Authentication failed") + mock_smtp.return_value = mock_smtp_instance + + with app.app_context(): + response = self.app.post('/v1/send/pdf', data={ + 'subject': 'Test Subject', + 'message': 'Test Message', + 'to_email': 'test@example.com', + }, content_type='multipart/form-data', files={'pdf_blob': ('document.pdf', b'PDF_CONTENT')}) + + self.assertEqual(response.status_code, 500) + mock_smtp_instance.starttls.assert_called_once() + mock_smtp_instance.login.assert_called_once() + mock_smtp_instance.sendmail.assert_not_called() + mock_smtp_instance.quit.assert_called_once() + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/test_starttls.py b/src/tests/test_starttls.py new file mode 100644 index 0000000..1792916 --- /dev/null +++ b/src/tests/test_starttls.py @@ -0,0 +1,44 @@ +import smtplib +import os + +# Get SMTP settings from environment variables +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') + +print(f"Testing connection to {smtp_server}:{smtp_port}") + +# Create connection +server = smtplib.SMTP(smtp_server, smtp_port) + +# Enable debug output +server.set_debuglevel(1) + +# Test EHLO and STARTTLS +try: + print("Sending EHLO...") + server.ehlo() + print("EHLO response received. Checking for STARTTLS support...") + + server.starttls() + print("STARTTLS successful") + + print("Attempting login...") + server.login(smtp_username, smtp_password) + print("Login successful") + + server.quit() + print("Connection test completed successfully") +except Exception as e: + print(f"Error: {e}") + print("Testing SSL connection instead...") + + try: + ssl_server = smtplib.SMTP_SSL(smtp_server, smtp_port) + ssl_server.set_debuglevel(1) + ssl_server.login(smtp_username, smtp_password) + print("SSL connection successful") + ssl_server.quit() + except Exception as ssl_e: + print(f"SSL Error: {ssl_e}")