feat: Add Twisted SMTP relay server

Python SMTP relay server built with Twisted framework.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 17:05:30 +00:00
parent fdd2a30b77
commit b6b534efca
22 changed files with 1275 additions and 0 deletions

35
smtprelay/.github/workflows/ci.yml vendored Normal file
View File

@@ -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

49
smtprelay/.github/workflows/docker.yml vendored Normal file
View File

@@ -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

207
smtprelay/.gitignore vendored Normal file
View File

@@ -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__/

29
smtprelay/Dockerfile Normal file
View File

@@ -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"]

21
smtprelay/LICENSE Normal file
View File

@@ -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.

85
smtprelay/README.md Normal file
View File

@@ -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`

View File

@@ -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

6
smtprelay/renovate.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@@ -0,0 +1,5 @@
twisted>=24.7.0
cryptography
pyopenssl
service-identity
idna

View File

@@ -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())

View File

@@ -0,0 +1 @@
"""smtp_relay package."""

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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"""<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{_esc(title)}</title>
<style>
body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial;
margin: 24px; line-height: 1.4; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ padding: 8px 10px; border-bottom: 1px solid #ddd; }}
th {{ text-align: left; }}
.muted {{ color: #666; }}
.pill {{ display: inline-block; padding: 2px 8px; border-radius: 999px;
border: 1px solid #ccc; font-size: 12px; }}
a {{ text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
code {{ background: #f6f6f6; padding: 2px 6px; border-radius: 6px; }}
</style>
</head>
<body>
{body}
</body>
</html>"""
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"""
<h1>Twisted SMTP Relay</h1>
<p class="muted">Dashboard</p>
<h2>Stats</h2>
<ul>
<li>Started: <code>{_esc(_fmt_dt(s.started_at))}</code></li>
<li>Received: <code>{s.received_total}</code></li>
<li>Relayed OK: <code>{s.relayed_ok_total}</code></li>
<li>Relayed Fail: <code>{s.relayed_fail_total}</code></li>
<li>Stored: <code>{s.stored_count}</code></li>
</ul>
<p><a href="/messages">View messages</a></p>
"""
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(
"<tr>"
f"<td><a href='/messages/{_esc(m.message_id)}'>{_esc(m.message_id)}</a></td>"
f"<td><span class='{pill}'>{_esc(status)}</span></td>"
f"<td>{_esc(_fmt_dt(m.received_at))}</td>"
f"<td>{_esc(m.envelope_from)}</td>"
f"<td>{_esc(', '.join(m.envelope_to))}</td>"
f"<td>{_esc(subj)}</td>"
f"<td>{m.size_bytes}</td>"
"</tr>"
)
body = """
<h1>Messages</h1>
<p><a href="/">Back to dashboard</a></p>
<table>
<thead>
<tr>
<th>ID</th><th>Status</th><th>Received</th><th>MAIL FROM</th>
<th>RCPT TO</th><th>Subject</th><th>Size</th>
</tr>
</thead>
<tbody>
""" + "\n".join(rows) + """
</tbody>
</table>
"""
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", "<h1>Not found</h1><p><a href='/messages'>Back</a></p>")
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"""
<h1>Message {_esc(item.message_id)}</h1>
<p><a href="/messages">Back to messages</a> | <a href="/">Dashboard</a></p>
<h2>Envelope</h2>
<ul>
<li>Peer: <code>{_esc(item.peer)}</code></li>
<li>HELO: <code>{_esc(item.helo or '')}</code></li>
<li>MAIL FROM: <code>{_esc(item.envelope_from)}</code></li>
<li>RCPT TO: <code>{_esc(', '.join(item.envelope_to))}</code></li>
</ul>
<h2>Content</h2>
<ul>
<li>Subject: <code>{_esc(item.subject or '')}</code></li>
<li>Size: <code>{item.size_bytes}</code></li>
<li>SHA-256: <code>{_esc(item.sha256)}</code></li>
<li>Received: <code>{_esc(_fmt_dt(item.received_at))}</code></li>
</ul>
<h2>Relay</h2>
<ul>
<li>Status: <code>{_esc(status)}</code></li>
<li>Started: <code>{_esc(started)}</code></li>
<li>Finished: <code>{_esc(finished)}</code></li>
</ul>
<pre>{_esc(err)}</pre>
"""
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)

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1 @@

View File

@@ -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"])

View File

@@ -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"))