mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
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:
35
smtprelay/.github/workflows/ci.yml
vendored
Normal file
35
smtprelay/.github/workflows/ci.yml
vendored
Normal 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
49
smtprelay/.github/workflows/docker.yml
vendored
Normal 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
207
smtprelay/.gitignore
vendored
Normal 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
29
smtprelay/Dockerfile
Normal 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
21
smtprelay/LICENSE
Normal 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
85
smtprelay/README.md
Normal 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`
|
||||
19
smtprelay/docker-compose.yml
Normal file
19
smtprelay/docker-compose.yml
Normal 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
6
smtprelay/renovate.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
5
smtprelay/requirements.txt
Normal file
5
smtprelay/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
twisted>=24.7.0
|
||||
cryptography
|
||||
pyopenssl
|
||||
service-identity
|
||||
idna
|
||||
34
smtprelay/scripts/send_test_mail.py
Normal file
34
smtprelay/scripts/send_test_mail.py
Normal 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())
|
||||
1
smtprelay/smtp_relay/__init__.py
Normal file
1
smtprelay/smtp_relay/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""smtp_relay package."""
|
||||
31
smtprelay/smtp_relay/app.py
Normal file
31
smtprelay/smtp_relay/app.py
Normal 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()
|
||||
104
smtprelay/smtp_relay/config.py
Normal file
104
smtprelay/smtp_relay/config.py
Normal 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,
|
||||
)
|
||||
201
smtprelay/smtp_relay/http_server.py
Normal file
201
smtprelay/smtp_relay/http_server.py
Normal 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)
|
||||
21
smtprelay/smtp_relay/main.py
Normal file
21
smtprelay/smtp_relay/main.py
Normal 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())
|
||||
39
smtprelay/smtp_relay/models.py
Normal file
39
smtprelay/smtp_relay/models.py
Normal 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
|
||||
75
smtprelay/smtp_relay/relay_client.py
Normal file
75
smtprelay/smtp_relay/relay_client.py
Normal 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
|
||||
138
smtprelay/smtp_relay/smtp_server.py
Normal file
138
smtprelay/smtp_relay/smtp_server.py
Normal 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
|
||||
105
smtprelay/smtp_relay/store.py
Normal file
105
smtprelay/smtp_relay/store.py
Normal 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)
|
||||
1
smtprelay/tests/__init__.py
Normal file
1
smtprelay/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
47
smtprelay/tests/test_config.py
Normal file
47
smtprelay/tests/test_config.py
Normal 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"])
|
||||
22
smtprelay/tests/test_store.py
Normal file
22
smtprelay/tests/test_store.py
Normal 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"))
|
||||
Reference in New Issue
Block a user