diff --git a/infra/README.md b/infra/README.md index dd2a0e0..343b114 100644 --- a/infra/README.md +++ b/infra/README.md @@ -8,13 +8,14 @@ Local Docker-based test services for ScadaLink development. docker compose up -d ``` -This starts three services: +This starts four services: | Service | Port | Purpose | |---------|------|---------| | OPC UA (Azure IoT OPC PLC) | 50000 (OPC UA), 8080 (web) | Simulated OPC UA server with ScadaLink-style tags | | LDAP (GLAuth) | 3893 | Lightweight LDAP with test users/groups matching ScadaLink roles | | MS SQL 2022 | 1433 | Configuration and machine data databases | +| SMTP (Mailpit) | 1025 (SMTP), 8025 (web) | Email capture for notification testing | ## First-Time SQL Setup @@ -37,7 +38,7 @@ docker compose down **Stop a single service** (leave the others running): ```bash -docker compose stop opcua # or: ldap, mssql +docker compose stop opcua # or: ldap, mssql, smtp docker compose start opcua # bring it back without recreating ``` @@ -70,11 +71,12 @@ pip install -r tools/requirements.txt > The `.venv` directory is gitignored. -**Quick readiness check** (all three services, with venv active): +**Quick readiness check** (all four services, with venv active): ```bash python tools/opcua_tool.py check python tools/ldap_tool.py check python tools/mssql_tool.py check +python tools/smtp_tool.py check ``` | Tool | Service | Key Commands | @@ -82,6 +84,7 @@ python tools/mssql_tool.py check | `tools/opcua_tool.py` | OPC UA | `check`, `browse`, `read`, `write`, `monitor` | | `tools/ldap_tool.py` | LDAP | `check`, `bind`, `search`, `users`, `groups` | | `tools/mssql_tool.py` | MS SQL | `check`, `setup`, `query`, `tables` | +| `tools/smtp_tool.py` | SMTP (Mailpit) | `check`, `send`, `list`, `read`, `clear` | Each tool supports `--help` for full usage. See the per-service docs below for detailed examples. @@ -93,3 +96,4 @@ See the project root for per-service setup guides: - [test_infra_opcua.md](../test_infra_opcua.md) — OPC UA server details - [test_infra_ldap.md](../test_infra_ldap.md) — LDAP server details - [test_infra_db.md](../test_infra_db.md) — MS SQL database details +- [test_infra_smtp.md](../test_infra_smtp.md) — SMTP server details (Mailpit) diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index bd99541..f68d462 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -41,5 +41,17 @@ services: - ./mssql/setup.sql:/docker-entrypoint-initdb.d/setup.sql:ro restart: unless-stopped + smtp: + image: axllent/mailpit:latest + container_name: scadalink-smtp + ports: + - "1025:1025" + - "8025:8025" + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + MP_MAX_MESSAGES: 500 + restart: unless-stopped + volumes: scadalink-mssql-data: diff --git a/infra/tools/smtp_tool.py b/infra/tools/smtp_tool.py new file mode 100644 index 0000000..1d10c88 --- /dev/null +++ b/infra/tools/smtp_tool.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""SMTP/Mailpit client tool for ScadaLink test infrastructure.""" + +import argparse +import email.mime.text +import json +import smtplib +import sys +import urllib.request + + +DEFAULT_SMTP_HOST = "localhost" +DEFAULT_SMTP_PORT = 1025 +DEFAULT_API_URL = "http://localhost:8025/api" + + +def cmd_check(args): + """Test SMTP connectivity and report Mailpit status.""" + # Test SMTP connection + try: + server = smtplib.SMTP(args.host, args.port, timeout=5) + server.ehlo() + smtp_ok = True + server.quit() + except Exception as e: + smtp_ok = False + smtp_err = str(e) + + # Test API/web UI + try: + req = urllib.request.Request(f"{args.api}/v1/info") + with urllib.request.urlopen(req, timeout=5) as resp: + info = json.loads(resp.read()) + api_ok = True + except Exception as e: + api_ok = False + api_err = str(e) + + print(f"SMTP ({args.host}:{args.port}): {'OK' if smtp_ok else 'FAILED - ' + smtp_err}") + print(f"Web UI/API ({args.api}): {'OK' if api_ok else 'FAILED - ' + api_err}") + + if api_ok: + print(f"\nMailpit version: {info.get('Version', 'unknown')}") + print(f"Database path: {info.get('DatabasePath', 'unknown')}") + messages = info.get('Messages', 0) + print(f"Stored messages: {messages}") + + if smtp_ok and api_ok: + print("\nSMTP server is healthy.") + else: + sys.exit(1) + + +def cmd_send(args): + """Send a test email via SMTP.""" + msg = email.mime.text.MIMEText(args.body, "plain") + msg["Subject"] = args.subject + msg["From"] = args.sender + msg["To"] = args.to + + if args.bcc: + recipients = [args.to] + [b.strip() for b in args.bcc.split(",")] + else: + recipients = [args.to] + + try: + server = smtplib.SMTP(args.host, args.port, timeout=5) + server.ehlo() + server.sendmail(args.sender, recipients, msg.as_string()) + server.quit() + bcc_note = f" (BCC: {args.bcc})" if args.bcc else "" + print(f"Sent: {args.sender} -> {args.to}{bcc_note}") + print(f"Subject: {args.subject}") + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_list(args): + """List messages in Mailpit inbox.""" + try: + req = urllib.request.Request(f"{args.api}/v1/messages?limit={args.limit}") + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + total = data.get("messages_count", 0) + messages = data.get("messages", []) + + print(f"Messages: {len(messages)} shown / {total} total\n") + + if not messages: + print("(inbox empty)") + return + + print(f"{'ID':<24} {'Date':<22} {'From':<30} {'To':<30} {'Subject'}") + print("-" * 130) + + for msg in messages: + msg_id = msg["ID"] + date = msg.get("Date", "")[:21] + from_addr = msg.get("From", {}).get("Address", "")[:28] + to_list = msg.get("To", []) + to_addr = (to_list[0].get("Address", "") if to_list else "")[:28] + subject = msg.get("Subject", "")[:40] + print(f"{msg_id:<24} {date:<22} {from_addr:<30} {to_addr:<30} {subject}") + + +def cmd_read(args): + """Read a specific message by ID.""" + try: + req = urllib.request.Request(f"{args.api}/v1/message/{args.id}") + with urllib.request.urlopen(req, timeout=5) as resp: + msg = json.loads(resp.read()) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print(f"ID: {msg['ID']}") + print(f"Date: {msg.get('Date', '')}") + from_info = msg.get("From", {}) + print(f"From: {from_info.get('Name', '')} <{from_info.get('Address', '')}>") + + for field in ["To", "Cc", "Bcc"]: + addrs = msg.get(field, []) + if addrs: + formatted = ", ".join(f"{a.get('Name', '')} <{a.get('Address', '')}>" for a in addrs) + print(f"{field}: {formatted}") + + print(f"Subject: {msg.get('Subject', '')}") + print() + print(msg.get("Text", "(no text body)")) + + +def cmd_clear(args): + """Delete all messages in Mailpit.""" + try: + req = urllib.request.Request(f"{args.api}/v1/messages", method="DELETE") + with urllib.request.urlopen(req, timeout=5) as resp: + resp.read() + print("All messages deleted.") + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description="SMTP/Mailpit client tool for ScadaLink test infrastructure") + parser.add_argument("--host", default=DEFAULT_SMTP_HOST, help=f"SMTP host (default: {DEFAULT_SMTP_HOST})") + parser.add_argument("--port", type=int, default=DEFAULT_SMTP_PORT, help=f"SMTP port (default: {DEFAULT_SMTP_PORT})") + parser.add_argument("--api", default=DEFAULT_API_URL, help=f"Mailpit API URL (default: {DEFAULT_API_URL})") + + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("check", help="Test SMTP connectivity and Mailpit status") + + send_p = sub.add_parser("send", help="Send a test email") + send_p.add_argument("--sender", default="scada-notifications@company.com", + help="From address (default: scada-notifications@company.com)") + send_p.add_argument("--to", required=True, help="Recipient address") + send_p.add_argument("--bcc", help="Comma-separated BCC addresses") + send_p.add_argument("--subject", default="Test notification", help="Subject line") + send_p.add_argument("--body", default="This is a test notification from ScadaLink.", help="Message body") + + list_p = sub.add_parser("list", help="List messages in Mailpit inbox") + list_p.add_argument("--limit", type=int, default=20, help="Number of messages to show (default: 20)") + + read_p = sub.add_parser("read", help="Read a specific message by ID") + read_p.add_argument("--id", required=True, help="Message ID (from list command)") + + sub.add_parser("clear", help="Delete all messages") + + args = parser.parse_args() + + commands = { + "check": cmd_check, + "send": cmd_send, + "list": cmd_list, + "read": cmd_read, + "clear": cmd_clear, + } + + commands[args.command](args) + + +if __name__ == "__main__": + main() diff --git a/test_infra.md b/test_infra.md index a7ba3fb..561cdaf 100644 --- a/test_infra.md +++ b/test_infra.md @@ -1,6 +1,6 @@ # Test Infrastructure -This document describes the local Docker-based test infrastructure for ScadaLink development. Three services provide the external dependencies needed to run and test the system locally. +This document describes the local Docker-based test infrastructure for ScadaLink development. Four services provide the external dependencies needed to run and test the system locally. ## Services @@ -9,6 +9,7 @@ This document describes the local Docker-based test infrastructure for ScadaLink | OPC UA Server | `mcr.microsoft.com/iotedge/opc-plc:latest` | 50000 (OPC UA), 8080 (web) | `infra/opcua/nodes.json` | | LDAP Server | `glauth/glauth:latest` | 3893 | `infra/glauth/config.toml` | | MS SQL 2022 | `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `infra/mssql/setup.sql` | +| SMTP (Mailpit) | `axllent/mailpit:latest` | 1025 (SMTP), 8025 (web) | Environment vars | ## Quick Start @@ -32,6 +33,7 @@ Each service has a dedicated document with configuration details, verification s - [test_infra_opcua.md](test_infra_opcua.md) — OPC UA test server (Azure IoT OPC PLC) - [test_infra_ldap.md](test_infra_ldap.md) — LDAP test server (GLAuth) - [test_infra_db.md](test_infra_db.md) — MS SQL 2022 database +- [test_infra_smtp.md](test_infra_smtp.md) — SMTP test server (Mailpit) ## Connection Strings @@ -51,6 +53,13 @@ For use in `appsettings.Development.json`: }, "OpcUa": { "EndpointUrl": "opc.tcp://localhost:50000" + }, + "Smtp": { + "Server": "localhost", + "Port": 1025, + "AuthMode": "None", + "FromAddress": "scada-notifications@company.com", + "ConnectionTimeout": 30 } } ``` @@ -60,7 +69,7 @@ For use in `appsettings.Development.json`: ```bash cd infra docker compose down # stop containers, preserve SQL data volume -docker compose stop opcua # stop a single service (also: ldap, mssql) +docker compose stop opcua # stop a single service (also: ldap, mssql, smtp) ``` **Full teardown** (removes volumes, optionally images and venv): @@ -77,11 +86,11 @@ After a full teardown, the next `docker compose up -d` starts fresh — re-run t ``` infra/ - docker-compose.yml # All three services + docker-compose.yml # All four services teardown.sh # Teardown script (volumes, images, venv) glauth/config.toml # LDAP users and groups mssql/setup.sql # Database and user creation opcua/nodes.json # Custom OPC UA tag definitions - tools/ # Python CLI tools (opcua, ldap, mssql) + tools/ # Python CLI tools (opcua, ldap, mssql, smtp) README.md # Quick-start for the infra folder ``` diff --git a/test_infra_smtp.md b/test_infra_smtp.md new file mode 100644 index 0000000..76f72d4 --- /dev/null +++ b/test_infra_smtp.md @@ -0,0 +1,116 @@ +# Test Infrastructure: SMTP Server (Mailpit) + +## Overview + +The test SMTP server uses [Mailpit](https://mailpit.axllent.org/), a lightweight email testing tool that captures all outgoing emails without delivering them. It provides both an SMTP server for sending and a web UI for inspecting captured messages. + +## Image & Ports + +- **Image**: `axllent/mailpit:latest` +- **SMTP port**: 1025 +- **Web UI / API**: `http://localhost:8025` + +## Configuration + +| Setting | Value | Description | +|---------|-------|-------------| +| `MP_SMTP_AUTH_ACCEPT_ANY` | `1` | Accept any SMTP credentials (or none) — no real authentication | +| `MP_SMTP_AUTH_ALLOW_INSECURE` | `1` | Allow auth over plain SMTP (no TLS required) — dev only | +| `MP_MAX_MESSAGES` | `500` | Maximum stored messages before oldest are auto-deleted | + +Mailpit accepts all emails regardless of sender/recipient domain. No emails leave the server — they are captured and viewable in the web UI. + +## SMTP Connection Settings + +For `appsettings.Development.json` (Notification Service): + +```json +{ + "Smtp": { + "Server": "localhost", + "Port": 1025, + "AuthMode": "None", + "FromAddress": "scada-notifications@company.com", + "ConnectionTimeout": 30 + } +} +``` + +Since `MP_SMTP_AUTH_ACCEPT_ANY` is enabled, the Notification Service can use any auth mode: +- **No auth**: Connect directly, no credentials needed. +- **Basic Auth**: Any username/password will be accepted (useful for testing the auth code path without a real server). +- **OAuth2**: Not supported by Mailpit. For OAuth2 testing, use a real Microsoft 365 tenant. + +## Mailpit API + +Mailpit exposes a REST API at `http://localhost:8025/api` for programmatic access: + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/info` | GET | Server info (version, message count) | +| `/api/v1/messages` | GET | List messages (supports `?limit=N`) | +| `/api/v1/message/{id}` | GET | Read a specific message | +| `/api/v1/messages` | DELETE | Delete all messages | +| `/api/v1/search?query=...` | GET | Search messages | + +## Verification + +1. Check the container is running: + +```bash +docker ps --filter name=scadalink-smtp +``` + +2. Open the web UI at `http://localhost:8025` to view captured emails. + +3. Send a test email using `curl` or any SMTP client: + +```bash +# Using Python's smtplib (one-liner) +python3 -c " +import smtplib; from email.mime.text import MIMEText +msg = MIMEText('Test body'); msg['Subject'] = 'Test'; msg['From'] = 'test@example.com'; msg['To'] = 'user@example.com' +smtplib.SMTP('localhost', 1025).sendmail('test@example.com', ['user@example.com'], msg.as_string()) +print('Sent') +" +``` + +## CLI Tool + +The `infra/tools/smtp_tool.py` script provides a convenient CLI for interacting with the SMTP server and Mailpit API. This tool uses only Python standard library modules — no additional dependencies required. + +**Commands**: + +```bash +# Check SMTP connectivity and Mailpit status +python infra/tools/smtp_tool.py check + +# Send a test email +python infra/tools/smtp_tool.py send --to user@example.com --subject "Alarm: Tank High Level" --body "Tank level exceeded 95%" + +# Send with BCC (matches ScadaLink notification delivery pattern) +python infra/tools/smtp_tool.py send --to scada-notifications@company.com --bcc "operator1@company.com,operator2@company.com" --subject "Shift Report" + +# List captured messages +python infra/tools/smtp_tool.py list + +# Read a specific message by ID +python infra/tools/smtp_tool.py read --id + +# Clear all messages +python infra/tools/smtp_tool.py clear +``` + +Use `--host` and `--port` to override SMTP defaults (localhost:1025), `--api` for the Mailpit API URL. Run with `--help` for full usage. + +## Relevance to ScadaLink Components + +- **Notification Service** — test SMTP delivery, BCC recipient handling, plain-text formatting, and store-and-forward retry behavior (Mailpit can be stopped/started to simulate transient failures). +- **Store-and-Forward Engine** — verify buffered retry by stopping the SMTP container and observing queued notifications. + +## Notes + +- Mailpit does **not** support OAuth2 Client Credentials authentication. To test the OAuth2 code path, use a real Microsoft 365 tenant (see Q12 in `docs/plans/questions.md`). +- To simulate SMTP failures for store-and-forward testing, stop the container: `docker compose stop smtp`. Restart with `docker compose start smtp`. +- The web UI at `http://localhost:8025` provides real-time message inspection, search, and message source viewing. +- No data persistence — messages are stored in a temporary database inside the container and lost on container removal.