From b6b534efca903ea0dbf37537b12a153d2b514ca8 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Wed, 21 Jan 2026 17:05:30 +0000 Subject: [PATCH] feat: Add Twisted SMTP relay server Python SMTP relay server built with Twisted framework. Co-Authored-By: Claude Opus 4.5 --- smtprelay/.github/workflows/ci.yml | 35 +++++ smtprelay/.github/workflows/docker.yml | 49 ++++++ smtprelay/.gitignore | 207 +++++++++++++++++++++++++ smtprelay/Dockerfile | 29 ++++ smtprelay/LICENSE | 21 +++ smtprelay/README.md | 85 ++++++++++ smtprelay/docker-compose.yml | 19 +++ smtprelay/renovate.json | 6 + smtprelay/requirements.txt | 5 + smtprelay/scripts/send_test_mail.py | 34 ++++ smtprelay/smtp_relay/__init__.py | 1 + smtprelay/smtp_relay/app.py | 31 ++++ smtprelay/smtp_relay/config.py | 104 +++++++++++++ smtprelay/smtp_relay/http_server.py | 201 ++++++++++++++++++++++++ smtprelay/smtp_relay/main.py | 21 +++ smtprelay/smtp_relay/models.py | 39 +++++ smtprelay/smtp_relay/relay_client.py | 75 +++++++++ smtprelay/smtp_relay/smtp_server.py | 138 +++++++++++++++++ smtprelay/smtp_relay/store.py | 105 +++++++++++++ smtprelay/tests/__init__.py | 1 + smtprelay/tests/test_config.py | 47 ++++++ smtprelay/tests/test_store.py | 22 +++ 22 files changed, 1275 insertions(+) create mode 100644 smtprelay/.github/workflows/ci.yml create mode 100644 smtprelay/.github/workflows/docker.yml create mode 100644 smtprelay/.gitignore create mode 100644 smtprelay/Dockerfile create mode 100644 smtprelay/LICENSE create mode 100644 smtprelay/README.md create mode 100644 smtprelay/docker-compose.yml create mode 100644 smtprelay/renovate.json create mode 100644 smtprelay/requirements.txt create mode 100644 smtprelay/scripts/send_test_mail.py create mode 100644 smtprelay/smtp_relay/__init__.py create mode 100644 smtprelay/smtp_relay/app.py create mode 100644 smtprelay/smtp_relay/config.py create mode 100644 smtprelay/smtp_relay/http_server.py create mode 100644 smtprelay/smtp_relay/main.py create mode 100644 smtprelay/smtp_relay/models.py create mode 100644 smtprelay/smtp_relay/relay_client.py create mode 100644 smtprelay/smtp_relay/smtp_server.py create mode 100644 smtprelay/smtp_relay/store.py create mode 100644 smtprelay/tests/__init__.py create mode 100644 smtprelay/tests/test_config.py create mode 100644 smtprelay/tests/test_store.py diff --git a/smtprelay/.github/workflows/ci.yml b/smtprelay/.github/workflows/ci.yml new file mode 100644 index 000000000..1f9542c92 --- /dev/null +++ b/smtprelay/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python: ["3.10", "3.11", "3.12"] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Install + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Run unit tests + run: | + python -m unittest -v diff --git a/smtprelay/.github/workflows/docker.yml b/smtprelay/.github/workflows/docker.yml new file mode 100644 index 000000000..426efaf94 --- /dev/null +++ b/smtprelay/.github/workflows/docker.yml @@ -0,0 +1,49 @@ +name: Docker Build + +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + packages: write + +jobs: + docker-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Needed only for cross-platform builds (e.g. arm64 on amd64 runner) + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: all + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build (PR) / Build+Push (push) + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name == 'push' }} + # Choose the platforms you actually want: + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository_owner }}/smtp-relay:ci + ghcr.io/${{ github.repository_owner }}/smtp-relay:${{ github.sha }} + # Optional: enable GH Actions cache for faster rebuilds + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/smtprelay/.gitignore b/smtprelay/.gitignore new file mode 100644 index 000000000..b7faf403d --- /dev/null +++ b/smtprelay/.gitignore @@ -0,0 +1,207 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/smtprelay/Dockerfile b/smtprelay/Dockerfile new file mode 100644 index 000000000..d27890f46 --- /dev/null +++ b/smtprelay/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Minimal system deps for TLS/certs +RUN apt-get update \ + && apt-get install -y ca-certificates libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY smtp_relay /app/smtp_relay +COPY scripts /app/scripts +COPY README.md /app/README.md + +# Default ports (override via env as needed) +EXPOSE 2525 +EXPOSE 8080 + +# Simple healthcheck: dashboard should respond. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/', timeout=3).read()" || exit 1 + +CMD ["python", "-m", "smtp_relay.main"] diff --git a/smtprelay/LICENSE b/smtprelay/LICENSE new file mode 100644 index 000000000..3d0cd4b58 --- /dev/null +++ b/smtprelay/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 johndoe6345789 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/smtprelay/README.md b/smtprelay/README.md new file mode 100644 index 000000000..f43cccb12 --- /dev/null +++ b/smtprelay/README.md @@ -0,0 +1,85 @@ +# Twisted SMTP Ingest + Gmail Relay + HTTP Stats + +Set SMTP_LISTEN_HOST=0.0.0.0 and HTTP_LISTEN_HOST=0.0.0.0 so Twisted binds the SMTP and HTTP listeners to all interfaces. + +- **SMTP server** (Twisted) listens on a **non-privileged port (>1024)** and accepts mail. +- **SMTP client** (Twisted) relays received mail to **Gmail SMTP (submission)** via STARTTLS. +- **HTTP server** (Twisted Web) serves a simple dashboard with: + - server stats (uptime, counters) + - list of relayed messages (recent first) + - per-message detail view + +## Install +```bash +python -m venv .venv +# Windows: +.venv\Scripts\python -m pip install -r requirements.txt +# Linux/macOS: +.venv/bin/python -m pip install -r requirements.txt +``` + +## Configure (environment variables) +Required: +- `GMAIL_USERNAME` +- `GMAIL_APP_PASSWORD` +- `FORWARD_TO` (comma-separated list) + +Optional: +- `SMTP_LISTEN_HOST` (default: 127.0.0.1) +- `SMTP_LISTEN_PORT` (default: 2525, must be >1024) +- `HTTP_LISTEN_HOST` (default: 127.0.0.1) +- `HTTP_LISTEN_PORT` (default: 8080, must be >1024) +- `GMAIL_HOST` (default: smtp.gmail.com) +- `GMAIL_PORT` (default: 587) +- `RELAY_FROM` (default: GMAIL_USERNAME) +- `ALLOW_ANY_RCPT` (default: true) - if false, only accept RCPT that match FORWARD_TO. +- `ADD_X_HEADERS` (default: true) - add X-Original-* headers. +- `MAX_STORE` (default: 200) - max number of message records kept in memory. + +## Run +PowerShell: +```powershell +$env:GMAIL_USERNAME="you@gmail.com" +$env:GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" +$env:FORWARD_TO="you@gmail.com" +$env:SMTP_LISTEN_PORT="2525" +$env:HTTP_LISTEN_PORT="8080" +.venv\Scripts\python -m smtp_relay.main +``` + +Open dashboard: +- http://127.0.0.1:8080/ + +## Send a test email (to your local relay) +```bash +python scripts/send_test_mail.py --host 127.0.0.1 --port 2525 --to you@gmail.com +``` + +## Tests +```bash +python -m unittest -v +``` + +## Security notes +This is intended for local / controlled networks. If you expose it publicly: +- firewall/VPN it +- authenticate clients +- rate limit +- set `ALLOW_ANY_RCPT=false` + + +## Docker + +Build and run with compose: +```bash +docker compose up --build +``` + +Then: +- SMTP: localhost:2525 +- Dashboard: http://localhost:8080/ + +Provide env vars via your shell or a `.env` file: +- `GMAIL_USERNAME` +- `GMAIL_APP_PASSWORD` +- `FORWARD_TO` diff --git a/smtprelay/docker-compose.yml b/smtprelay/docker-compose.yml new file mode 100644 index 000000000..b4c921cfe --- /dev/null +++ b/smtprelay/docker-compose.yml @@ -0,0 +1,19 @@ +services: + smtp-relay: + build: . + ports: + - "2525:2525" + - "8080:8080" + environment: + # Required: + - GMAIL_USERNAME=${GMAIL_USERNAME} + - GMAIL_APP_PASSWORD=${GMAIL_APP_PASSWORD} + - FORWARD_TO=${FORWARD_TO} + # Optional: + - SMTP_LISTEN_HOST=0.0.0.0 + - SMTP_LISTEN_PORT=2525 + - HTTP_LISTEN_HOST=0.0.0.0 + - HTTP_LISTEN_PORT=8080 + - ALLOW_ANY_RCPT=true + - ADD_X_HEADERS=true + - MAX_STORE=200 diff --git a/smtprelay/renovate.json b/smtprelay/renovate.json new file mode 100644 index 000000000..5db72dd6a --- /dev/null +++ b/smtprelay/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/smtprelay/requirements.txt b/smtprelay/requirements.txt new file mode 100644 index 000000000..11da7711d --- /dev/null +++ b/smtprelay/requirements.txt @@ -0,0 +1,5 @@ +twisted>=24.7.0 +cryptography +pyopenssl +service-identity +idna diff --git a/smtprelay/scripts/send_test_mail.py b/smtprelay/scripts/send_test_mail.py new file mode 100644 index 000000000..155965a7f --- /dev/null +++ b/smtprelay/scripts/send_test_mail.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import argparse +import smtplib +from email.message import EmailMessage + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Send a test email to the local relay.") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=2525) + p.add_argument("--to", required=True) + p.add_argument("--from-addr", dest="from_addr", default="test@example.com") + p.add_argument("--subject", default="Twisted relay test") + p.add_argument("--body", default="Hello from the Twisted SMTP relay test.") + return p + + +def main() -> int: + args = _build_parser().parse_args() + msg = EmailMessage() + msg["From"] = args.from_addr + msg["To"] = args.to + msg["Subject"] = args.subject + msg.set_content(args.body) + + with smtplib.SMTP(args.host, args.port, timeout=10) as s: + s.send_message(msg) + print("Sent.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/smtprelay/smtp_relay/__init__.py b/smtprelay/smtp_relay/__init__.py new file mode 100644 index 000000000..aa9ff3820 --- /dev/null +++ b/smtprelay/smtp_relay/__init__.py @@ -0,0 +1 @@ +"""smtp_relay package.""" diff --git a/smtprelay/smtp_relay/app.py b/smtprelay/smtp_relay/app.py new file mode 100644 index 000000000..f9dfe1da4 --- /dev/null +++ b/smtprelay/smtp_relay/app.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from twisted.internet import reactor +from twisted.python import log + +from .config import RelayConfig +from .http_server import make_site +from .smtp_server import RelaySMTPFactory +from .store import MessageStore + + +def run(cfg: RelayConfig) -> None: + store = MessageStore(cfg.max_store) + + log.startLogging(open("/dev/stdout", "w")) # type: ignore[arg-type] + + smtp_factory = RelaySMTPFactory(cfg, store) + reactor.listenTCP(cfg.smtp_listen_port, smtp_factory, + interface=cfg.smtp_listen_host) + log.msg( + f"SMTP listening on {cfg.smtp_listen_host}:{cfg.smtp_listen_port}" + ) + + site = make_site(store) + reactor.listenTCP(cfg.http_listen_port, site, + interface=cfg.http_listen_host) + log.msg( + f"HTTP listening on {cfg.http_listen_host}:{cfg.http_listen_port}" + ) + + reactor.run() diff --git a/smtprelay/smtp_relay/config.py b/smtprelay/smtp_relay/config.py new file mode 100644 index 000000000..c46a3df49 --- /dev/null +++ b/smtprelay/smtp_relay/config.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from dataclasses import dataclass +from os import environ +from typing import List + + +def _get_env(name: str) -> str | None: + value = environ.get(name) + if value is None: + return None + value = value.strip() + return value if value else None + + +def _get_env_bool(name: str, default: bool) -> bool: + raw = _get_env(name) + if raw is None: + return default + return raw.lower() in {"1", "true", "yes", "y", "on"} + + +def _get_env_int(name: str, default: int) -> int: + raw = _get_env(name) + if raw is None: + return default + return int(raw) + + +def _split_csv(raw: str) -> List[str]: + parts = [p.strip() for p in raw.split(",")] + return [p for p in parts if p] + + +@dataclass(frozen=True, slots=True) +class RelayConfig: + smtp_listen_host: str + smtp_listen_port: int + http_listen_host: str + http_listen_port: int + + gmail_host: str + gmail_port: int + gmail_username: str + gmail_app_password: str + + relay_from: str + forward_to: List[str] + + allow_any_rcpt: bool + add_x_headers: bool + max_store: int + + @staticmethod + def from_env() -> "RelayConfig": + username = _get_env("GMAIL_USERNAME") + password = _get_env("GMAIL_APP_PASSWORD") + forward_to_raw = _get_env("FORWARD_TO") + + if not username: + raise ValueError("Missing env var: GMAIL_USERNAME") + if not password: + raise ValueError("Missing env var: GMAIL_APP_PASSWORD") + if not forward_to_raw: + raise ValueError("Missing env var: FORWARD_TO") + + smtp_host = _get_env("SMTP_LISTEN_HOST") or "127.0.0.1" + smtp_port = _get_env_int("SMTP_LISTEN_PORT", 2525) + if smtp_port <= 1024: + raise ValueError("SMTP_LISTEN_PORT must be > 1024") + + http_host = _get_env("HTTP_LISTEN_HOST") or "127.0.0.1" + http_port = _get_env_int("HTTP_LISTEN_PORT", 8080) + if http_port <= 1024: + raise ValueError("HTTP_LISTEN_PORT must be > 1024") + + gmail_host = _get_env("GMAIL_HOST") or "smtp.gmail.com" + gmail_port = _get_env_int("GMAIL_PORT", 587) + + relay_from = _get_env("RELAY_FROM") or username + forward_to = _split_csv(forward_to_raw) + + allow_any_rcpt = _get_env_bool("ALLOW_ANY_RCPT", True) + add_x_headers = _get_env_bool("ADD_X_HEADERS", True) + max_store = _get_env_int("MAX_STORE", 200) + + if max_store < 10: + raise ValueError("MAX_STORE must be >= 10") + + return RelayConfig( + smtp_listen_host=smtp_host, + smtp_listen_port=smtp_port, + http_listen_host=http_host, + http_listen_port=http_port, + gmail_host=gmail_host, + gmail_port=gmail_port, + gmail_username=username, + gmail_app_password=password, + relay_from=relay_from, + forward_to=forward_to, + allow_any_rcpt=allow_any_rcpt, + add_x_headers=add_x_headers, + max_store=max_store, + ) diff --git a/smtprelay/smtp_relay/http_server.py b/smtprelay/smtp_relay/http_server.py new file mode 100644 index 000000000..9fe10a24d --- /dev/null +++ b/smtprelay/smtp_relay/http_server.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import html +from datetime import datetime, timezone +from typing import List + +from twisted.web.resource import Resource +from twisted.web.server import Request, Site + +from .store import MessageStore +from .models import StoredMessage + + +def _fmt_dt(dt: datetime) -> str: + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def _esc(s: str) -> str: + return html.escape(s, quote=True) + + +def _page(title: str, body: str) -> bytes: + doc = f""" + + + + + {_esc(title)} + + + +{body} + +""" + return doc.encode("utf-8") + + +class Root(Resource): + isLeaf = False + + def __init__(self, store: MessageStore) -> None: + super().__init__() + self._store = store + self.putChild(b"", Dashboard(store)) + self.putChild(b"messages", Messages(store)) + + +class Dashboard(Resource): + isLeaf = True + + def __init__(self, store: MessageStore) -> None: + super().__init__() + self._store = store + + def render_GET(self, request: Request) -> bytes: + s = self._store.stats() + body = f""" +

Twisted SMTP Relay

+

Dashboard

+ +

Stats

+ + +

View messages

+""" + request.setHeader(b"content-type", b"text/html; charset=utf-8") + return _page("SMTP Relay Dashboard", body) + + +class Messages(Resource): + isLeaf = False + + def __init__(self, store: MessageStore) -> None: + super().__init__() + self._store = store + + def getChild(self, path: bytes, request: Request) -> Resource: + if path == b"" or path is None: + return self + return MessageDetail(self._store, path.decode("utf-8", errors="replace")) + + def render_GET(self, request: Request) -> bytes: + items = self._store.list_recent() + rows = [] + for m in items: + status = "PENDING" + pill = "pill" + if m.relay_attempt is not None: + status = "OK" if m.relay_attempt.ok else "FAIL" + subj = m.subject or "(no subject)" + rows.append( + "" + f"{_esc(m.message_id)}" + f"{_esc(status)}" + f"{_esc(_fmt_dt(m.received_at))}" + f"{_esc(m.envelope_from)}" + f"{_esc(', '.join(m.envelope_to))}" + f"{_esc(subj)}" + f"{m.size_bytes}" + "" + ) + body = """ +

Messages

+

Back to dashboard

+ + + + + + + + +""" + "\n".join(rows) + """ + +
IDStatusReceivedMAIL FROMRCPT TOSubjectSize
+""" + request.setHeader(b"content-type", b"text/html; charset=utf-8") + return _page("Messages", body) + + +class MessageDetail(Resource): + isLeaf = True + + def __init__(self, store: MessageStore, message_id: str) -> None: + super().__init__() + self._store = store + self._id = message_id + + def render_GET(self, request: Request) -> bytes: + item = self._store.get(self._id) + if item is None: + request.setResponseCode(404) + request.setHeader(b"content-type", b"text/html; charset=utf-8") + return _page("Not found", "

Not found

Back

") + + status = "PENDING" + err = "" + started = "" + finished = "" + if item.relay_attempt is not None: + status = "OK" if item.relay_attempt.ok else "FAIL" + started = _fmt_dt(item.relay_attempt.started_at) + if item.relay_attempt.finished_at is not None: + finished = _fmt_dt(item.relay_attempt.finished_at) + if item.relay_attempt.error: + err = item.relay_attempt.error + + body = f""" +

Message {_esc(item.message_id)}

+

Back to messages | Dashboard

+ +

Envelope

+ + +

Content

+ + +

Relay

+ + +
{_esc(err)}
+""" + request.setHeader(b"content-type", b"text/html; charset=utf-8") + return _page(f"Message {item.message_id}", body) + + +def make_site(store: MessageStore) -> Site: + root = Root(store) + return Site(root) diff --git a/smtprelay/smtp_relay/main.py b/smtprelay/smtp_relay/main.py new file mode 100644 index 000000000..6f6cfb826 --- /dev/null +++ b/smtprelay/smtp_relay/main.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import sys + +from .app import run +from .config import RelayConfig + + +def main(argv: list[str] | None = None) -> int: + _ = argv or sys.argv[1:] + try: + cfg = RelayConfig.from_env() + except ValueError as exc: + print(f"Config error: {exc}", file=sys.stderr) + return 2 + run(cfg) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/smtprelay/smtp_relay/models.py b/smtprelay/smtp_relay/models.py new file mode 100644 index 000000000..14f999505 --- /dev/null +++ b/smtprelay/smtp_relay/models.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import List + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass(frozen=True, slots=True) +class InboundMeta: + peer: str + helo: str | None + envelope_from: str + envelope_to: List[str] + + +@dataclass(frozen=True, slots=True) +class RelayAttempt: + started_at: datetime + finished_at: datetime | None + ok: bool + error: str | None + + +@dataclass(frozen=True, slots=True) +class StoredMessage: + message_id: str + received_at: datetime + peer: str + helo: str | None + envelope_from: str + envelope_to: List[str] + subject: str | None + size_bytes: int + sha256: str + relay_attempt: RelayAttempt | None diff --git a/smtprelay/smtp_relay/relay_client.py b/smtprelay/smtp_relay/relay_client.py new file mode 100644 index 000000000..e3ce4224c --- /dev/null +++ b/smtprelay/smtp_relay/relay_client.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from email import policy, message_from_bytes +from email.message import EmailMessage + +from twisted.internet import defer, reactor +from twisted.internet.ssl import optionsForClientTLS +from twisted.mail import smtp + +from .config import RelayConfig +from .models import InboundMeta + + +def _as_email_message(raw_bytes: bytes) -> EmailMessage: + msg = message_from_bytes(raw_bytes, policy=policy.default) + if isinstance(msg, EmailMessage): + return msg + em = EmailMessage() + for k, v in msg.items(): + em[k] = v + payload = msg.get_payload(decode=True) + if payload is None: + em.set_content("") + else: + try: + em.set_content(payload.decode("utf-8", errors="replace")) + except Exception: + em.set_content("") + return em + + +def add_x_headers(msg: EmailMessage, meta: InboundMeta) -> None: + msg["X-Original-Peer"] = meta.peer + if meta.helo: + msg["X-Original-HELO"] = meta.helo + msg["X-Original-Mail-From"] = meta.envelope_from + msg["X-Original-Rcpt-To"] = ", ".join(meta.envelope_to) + + +def extract_subject(raw_bytes: bytes) -> str | None: + msg = message_from_bytes(raw_bytes) + try: + subject = msg.get("Subject") + if subject is None: + return None + subject = str(subject).strip() + return subject if subject else None + except Exception: + return None + + +def relay_to_gmail( + cfg: RelayConfig, + raw_message: bytes, + meta: InboundMeta, +) -> defer.Deferred[None]: + msg = _as_email_message(raw_message) + if cfg.add_x_headers: + add_x_headers(msg, meta) + msg_bytes = msg.as_bytes() + + context = optionsForClientTLS(hostname=cfg.gmail_host) + + d: defer.Deferred[None] = smtp.sendmail( + cfg.gmail_host, + cfg.relay_from, + cfg.forward_to, + msg_bytes, + port=cfg.gmail_port, + username=cfg.gmail_username, + password=cfg.gmail_app_password, + requireTransportSecurity=True, + reactor=reactor + ) + return d diff --git a/smtprelay/smtp_relay/smtp_server.py b/smtprelay/smtp_relay/smtp_server.py new file mode 100644 index 000000000..4e309bf47 --- /dev/null +++ b/smtprelay/smtp_relay/smtp_server.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import List + +from twisted.internet import defer +from twisted.mail import smtp +from twisted.python import log + +from .config import RelayConfig +from .models import InboundMeta, RelayAttempt, utc_now +from .relay_client import extract_subject, relay_to_gmail +from .store import MessageStore + +from zope.interface import implementer + +def _norm_addr(addr: str) -> str: + return addr.strip().lower() + + +def _decode_addr(addr: smtp.Address) -> str: + return str(addr) + +@implementer(smtp.IMessage) +class _Message: + def __init__(self, cfg: RelayConfig, store: MessageStore, meta: InboundMeta) -> None: + self._cfg = cfg + self._store = store + self._meta = meta + self._lines: List[bytes] = [] + + def lineReceived(self, line: bytes) -> None: + self._lines.append(line) + + def eomReceived(self) -> defer.Deferred[None]: + raw = b"\n".join(self._lines) + b"\n" + subject = extract_subject(raw) + msg_id = self._store.add_received( + peer=self._meta.peer, + helo=self._meta.helo, + envelope_from=self._meta.envelope_from, + envelope_to=self._meta.envelope_to, + subject=subject, + raw_bytes=raw, + ) + attempt = RelayAttempt(started_at=utc_now(), finished_at=None, ok=False, + error=None) + + d = relay_to_gmail(self._cfg, raw, self._meta) + + def _ok(_: object) -> None: + finished = RelayAttempt( + started_at=attempt.started_at, + finished_at=utc_now(), + ok=True, + error=None, + ) + self._store.set_relay_attempt(msg_id, finished) + + def _fail(failure): # type: ignore[no-untyped-def] + finished = RelayAttempt( + started_at=attempt.started_at, + finished_at=utc_now(), + ok=False, + error=str(getattr(failure, "getErrorMessage", lambda: failure)()), + ) + self._store.set_relay_attempt(msg_id, finished) + log.err(failure, "Failed to relay message to Gmail") + return None + + d.addCallback(_ok) + d.addErrback(_fail) + return d + + def connectionLost(self) -> None: + self._lines.clear() + +@implementer(smtp.IMessageDelivery) +class _Delivery: + def __init__(self, cfg: RelayConfig, store: MessageStore) -> None: + self._cfg = cfg + self._store = store + self._peer = "unknown" + self._helo: str | None = None + self._mail_from = "" + self._rcpt_tos: List[str] = [] + + def receivedHeader(self, helo: smtp.IHelo, origin, recipients): # type: ignore[no-untyped-def] + return None + + def validateFrom(self, helo: smtp.IHelo, origin: smtp.Address): # type: ignore[no-untyped-def] + self._helo = getattr(helo, "host", None) if helo else None + self._mail_from = _decode_addr(origin) + return origin + + def validateTo(self, user: smtp.User): # type: ignore[no-untyped-def] + rcpt = _decode_addr(user.dest) + if not self._cfg.allow_any_rcpt: + allowed = {_norm_addr(a) for a in self._cfg.forward_to} + if _norm_addr(rcpt) not in allowed: + raise smtp.SMTPBadRcpt(user, b"550 relaying denied") + self._rcpt_tos.append(rcpt) + + def _mk() -> _Message: + meta = InboundMeta( + peer=self._peer, + helo=self._helo, + envelope_from=self._mail_from, + envelope_to=list(self._rcpt_tos), + ) + return _Message(self._cfg, self._store, meta) + + return _mk + + def setPeer(self, peer: str) -> None: + self._peer = peer + + +class _PeerTrackingESMTP(smtp.ESMTP): + def connectionMade(self) -> None: + super().connectionMade() + peer = self.transport.getPeer() + peer_str = f"{peer.host}:{peer.port}" + delivery = self.factory.delivery # type: ignore[attr-defined] + if hasattr(delivery, "setPeer"): + delivery.setPeer(peer_str) + + +class RelaySMTPFactory(smtp.SMTPFactory): + protocol = _PeerTrackingESMTP + def __init__(self, cfg, store): + self.delivery = _Delivery(cfg, store) + super().__init__() + + def buildProtocol(self, addr): + p = super().buildProtocol(addr) + # attach the delivery object so validateFrom/validateTo work + p.delivery = self.delivery + return p diff --git a/smtprelay/smtp_relay/store.py b/smtprelay/smtp_relay/store.py new file mode 100644 index 000000000..fa6d1ecbe --- /dev/null +++ b/smtprelay/smtp_relay/store.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import hashlib +import itertools +from collections import deque +from dataclasses import dataclass +from datetime import datetime +from typing import Deque, Dict, Iterable, List, Optional, Tuple + +from .models import RelayAttempt, StoredMessage, utc_now + + +@dataclass(frozen=True, slots=True) +class StatsSnapshot: + started_at: datetime + received_total: int + relayed_ok_total: int + relayed_fail_total: int + stored_count: int + + +class MessageStore: + def __init__(self, max_store: int) -> None: + self._max_store = max_store + self._started_at = utc_now() + self._seq = itertools.count(1) + self._order: Deque[str] = deque() + self._items: Dict[str, StoredMessage] = {} + self._received_total = 0 + self._relayed_ok_total = 0 + self._relayed_fail_total = 0 + + def started_at(self) -> datetime: + return self._started_at + + def stats(self) -> StatsSnapshot: + return StatsSnapshot( + started_at=self._started_at, + received_total=self._received_total, + relayed_ok_total=self._relayed_ok_total, + relayed_fail_total=self._relayed_fail_total, + stored_count=len(self._order), + ) + + def add_received( + self, + peer: str, + helo: str | None, + envelope_from: str, + envelope_to: List[str], + subject: str | None, + raw_bytes: bytes, + ) -> str: + self._received_total += 1 + seq = next(self._seq) + msg_id = f"{seq:08d}" + sha = hashlib.sha256(raw_bytes).hexdigest() + item = StoredMessage( + message_id=msg_id, + received_at=utc_now(), + peer=peer, + helo=helo, + envelope_from=envelope_from, + envelope_to=list(envelope_to), + subject=subject, + size_bytes=len(raw_bytes), + sha256=sha, + relay_attempt=None, + ) + self._items[msg_id] = item + self._order.appendleft(msg_id) + self._trim() + return msg_id + + def set_relay_attempt(self, message_id: str, attempt: RelayAttempt) -> None: + item = self._items.get(message_id) + if item is None: + return + if attempt.ok: + self._relayed_ok_total += 1 + else: + self._relayed_fail_total += 1 + self._items[message_id] = StoredMessage( + message_id=item.message_id, + received_at=item.received_at, + peer=item.peer, + helo=item.helo, + envelope_from=item.envelope_from, + envelope_to=item.envelope_to, + subject=item.subject, + size_bytes=item.size_bytes, + sha256=item.sha256, + relay_attempt=attempt, + ) + + def get(self, message_id: str) -> StoredMessage | None: + return self._items.get(message_id) + + def list_recent(self) -> List[StoredMessage]: + return [self._items[mid] for mid in list(self._order)] + + def _trim(self) -> None: + while len(self._order) > self._max_store: + tail = self._order.pop() + self._items.pop(tail, None) diff --git a/smtprelay/tests/__init__.py b/smtprelay/tests/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/smtprelay/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/smtprelay/tests/test_config.py b/smtprelay/tests/test_config.py new file mode 100644 index 000000000..a9517027c --- /dev/null +++ b/smtprelay/tests/test_config.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import os +import unittest + +from smtp_relay.config import RelayConfig + + +class TestConfig(unittest.TestCase): + def setUp(self) -> None: + self._old = dict(os.environ) + + def tearDown(self) -> None: + os.environ.clear() + os.environ.update(self._old) + + def _base_env(self) -> None: + os.environ["GMAIL_USERNAME"] = "u@example.com" + os.environ["GMAIL_APP_PASSWORD"] = "pw" + os.environ["FORWARD_TO"] = "dest@example.com" + + def test_requires_env(self) -> None: + os.environ.clear() + with self.assertRaises(ValueError): + RelayConfig.from_env() + + def test_smtp_port_must_be_gt_1024(self) -> None: + os.environ.clear() + self._base_env() + os.environ["SMTP_LISTEN_PORT"] = "1024" + with self.assertRaises(ValueError): + RelayConfig.from_env() + + def test_http_port_must_be_gt_1024(self) -> None: + os.environ.clear() + self._base_env() + os.environ["HTTP_LISTEN_PORT"] = "1024" + with self.assertRaises(ValueError): + RelayConfig.from_env() + + def test_parses_forward_to_csv(self) -> None: + os.environ.clear() + os.environ["GMAIL_USERNAME"] = "u@example.com" + os.environ["GMAIL_APP_PASSWORD"] = "pw" + os.environ["FORWARD_TO"] = "a@example.com, b@example.com,, " + cfg = RelayConfig.from_env() + self.assertEqual(cfg.forward_to, ["a@example.com", "b@example.com"]) diff --git a/smtprelay/tests/test_store.py b/smtprelay/tests/test_store.py new file mode 100644 index 000000000..16d1086de --- /dev/null +++ b/smtprelay/tests/test_store.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import unittest + +from smtp_relay.store import MessageStore + + +class TestStore(unittest.TestCase): + def test_trims(self) -> None: + s = MessageStore(max_store=10) + for i in range(20): + s.add_received( + peer="p", + helo=None, + envelope_from="a", + envelope_to=["b"], + subject=None, + raw_bytes=b"x" * i, + ) + self.assertEqual(s.stats().stored_count, 10) + self.assertIsNotNone(s.get("00000020")) + self.assertIsNone(s.get("00000001"))