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""" + +
+ + +Dashboard
+ +{_esc(_fmt_dt(s.started_at))}{s.received_total}{s.relayed_ok_total}{s.relayed_fail_total}{s.stored_count}| ID | Status | Received | MAIL FROM | +RCPT TO | Subject | Size | +
|---|
{_esc(item.peer)}{_esc(item.helo or '')}{_esc(item.envelope_from)}{_esc(', '.join(item.envelope_to))}{_esc(item.subject or '')}{item.size_bytes}{_esc(item.sha256)}{_esc(_fmt_dt(item.received_at))}{_esc(status)}{_esc(started)}{_esc(finished)}{_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"))