Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,56 @@ on:
branches: [ main ]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Comment thread
randallmorse marked this conversation as resolved.
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ".[dev]"
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .

unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install .
pip install pytest
pip install ".[dev]"
- name: Run unit tests
Comment thread
randallmorse marked this conversation as resolved.
run: pytest -v -m "not integration"

integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install .
pip install pytest
pip install ".[dev]"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Pull mail server image
Expand Down
59 changes: 59 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

SMTPBench is a multi-threaded SMTP load-testing CLI published to PyPI as `smtpbench`. It sends **real emails** to whatever host it resolves — see the warning in README.md before running it against anything.

## Commands

```bash
# Install for development (editable) + dev tools (pytest, ruff)
pip install -e ".[dev]"

# Lint and format (ruff owns both)
ruff check . # lint
ruff check . --fix # lint + autofix
ruff format . # format
ruff format --check . # verify formatting (what CI runs)

# Unit tests (fast, no external services)
pytest -v -m "not integration"

# A single test / class / method
pytest tests/test_smtpbench.py::TestMXLookup::test_mx_lookup_success -v

# Integration tests (require Docker — spins up a real Postfix mail server)
pytest -v -m "integration"
./tests/run_integration_test.sh # equivalent shell wrapper

# Run the tool locally
smtpbench recipient=test@local.lets.qa port=587 threads=5 messages=10
python -m smtpbench recipient=... port=... threads=... messages=...

# Publish to PyPI (interactive; runs unit tests first, refuses on version mismatch)
./deploy.sh
```

Ruff is the linter and formatter (configured in `pyproject.toml` under `[tool.ruff]`). The lint rule set is `E, F, I, W, UP` with `E501` ignored — line length is left to the formatter so the two don't fight. `BLE001` (blind-except) and the `global`-variable rules are intentionally *not* enabled: the broad `except Exception` in the send path and the module-global threading model are by design here.

## Architecture

Essentially the entire application lives in `smtpbench/cli.py` (~600 lines). `__init__.py` exposes `main` and the version; `__main__.py` enables `python -m smtpbench`. Understanding the file means understanding these things that span it:

- **Module-level global state.** Counters (`success_count`, `fail_count`, `retry_count`), the stop flag (`stop_requested`), run metadata (`run_uuid`, `run_timestamp`, `client_hostname`), the resolved `mx_hosts` list, and logging config (`log_dir`, `journal_*`, `debug_*`) are all module globals mutated across threads under a single `threading.Lock` (`lock`). Worker threads read/write these directly rather than passing state around. **Consequence:** tests must reset globals — `tests/conftest.py` has an autouse `reset_globals` fixture that does this before and after every test. New global state that affects test outcomes should be added there too.

- **Argument parsing is `key=value`, not argparse.** `parse_args()` splits `sys.argv` on the first `=` per token. `-v/--version/version` and `-h/--help/help/?` (and zero args) short-circuit with `sys.exit(0)` before parsing. Required keys (`recipient`, `port`, `threads`, `messages`) are validated in `main()`, not the parser. Booleans are strings compared to `"true"`.

- **Send path with MX failover.** `main()` → spawns `threads` worker threads → each `worker()` loops `messages` times (0 = infinite) → `send_email()` handles retry/backoff → `try_send_to_mx_hosts()` walks `mx_hosts` in priority order, returning on the first success. `mx_hosts` is populated either from `lb_host` (single host, skips DNS) or `mx_lookup_all()` (DNS MX records sorted by preference). A pre-flight `check_smtp_banner()` on the first host aborts early if the server is unreachable.

- **Structured JSON logging.** `setup_logging()` creates four file loggers (`success`, `fail`, `retry`, `debug`) named `{name}_{timestamp}_{uuid}.log` in `log_dir`. `log_json()` writes one JSON object per line. Debug logging is gated on `debug_enabled` and also flips `smtplib` debug level.

- **Attachments carry raw bytes internally but never in logs.** `build_attachment_configs()` returns dicts that include a `content` key (the actual bytes, from a file or synthetic `b"0" * size`). That `content` is stripped before anything is logged — both `attachment_metadata()` and `log_json()` filter out the `content` key. When touching attachment handling, preserve this separation: bytes flow to `create_message()`, only metadata flows to logs.

## Conventions that matter

- **Version lives in two files and must match.** `pyproject.toml` `version` and `smtpbench/__init__.py` `__version__`. `deploy.sh` aborts on mismatch; keep them in lockstep and update `CHANGELOG.md`.
- **Integration tests depend on external infrastructure.** They shell out to `docker compose -f docker-compose.test.yml`, pull `ghcr.io/lets-qa/local-test-mail-server`, send mail, and validate the resulting mbox at `test-mail/root`. They filter validated messages by the run UUID to avoid false positives from prior runs. The `dockerfile` (lowercase) is what the compose file builds.
- **CI** (`.github/workflows/pytest.yml`) runs unit and integration jobs separately on every PR to `main`.
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,27 @@ smtpbench \
```bash
git clone https://github.com/SMTPBench/SMTPBench.git
cd SMTPBench
pip install -e .
pip install pytest
pip install -e ".[dev]"
```

This installs the runtime dependencies plus the dev tools (`pytest` and `ruff`).

### Linting and Formatting

SMTPBench uses [Ruff](https://docs.astral.sh/ruff/) for both linting and formatting:

```bash
# Lint
ruff check .

# Lint and auto-fix
ruff check . --fix

# Format code
ruff format .

# Check formatting without changing files (as CI does)
ruff format --check .
```

### Running Tests
Expand Down Expand Up @@ -484,7 +503,8 @@ pytest -v

### CI/CD

Tests run automatically on pull requests via GitHub Actions:
Checks run automatically on pull requests via GitHub Actions:
- **Lint** - Ruff lint and format checks
- **Unit tests** - Fast tests without external dependencies
- **Integration tests** - Full end-to-end tests with Docker Compose

Expand Down
35 changes: 28 additions & 7 deletions deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,28 @@ rm -rf dist/ build/ *.egg-info
echo -e "${GREEN}✓ Cleaned${NC}"
echo ""

echo -e "${BLUE}Step 2: Running tests${NC}"
echo -e "${BLUE}Step 2: Linting${NC}"
# Resolve ruff from the same environment used for the build/upload steps below.
if [ -d ".venv" ] && [ -x ".venv/bin/ruff" ]; then
RUFF=".venv/bin/ruff"
elif [ ! -d ".venv" ] && command -v ruff &> /dev/null; then
RUFF="ruff"
else
echo -e "${RED}✗ ruff not found in the deployment environment. Install it with 'pip install .[dev]'. Aborting deployment.${NC}"
exit 1
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if ! "$RUFF" check .; then
echo -e "${RED}✗ Lint failed. Aborting deployment.${NC}"
exit 1
fi
if ! "$RUFF" format --check .; then
echo -e "${RED}✗ Formatting check failed. Run '$RUFF format .'. Aborting deployment.${NC}"
exit 1
fi
echo -e "${GREEN}✓ Lint and formatting passed${NC}"
echo ""

echo -e "${BLUE}Step 3: Running tests${NC}"
if command -v pytest &> /dev/null; then
pytest -v -m "not integration" --tb=short
if [ $? -ne 0 ]; then
Expand All @@ -89,7 +110,7 @@ else
fi
echo ""

echo -e "${BLUE}Step 3: Building package${NC}"
echo -e "${BLUE}Step 4: Building package${NC}"
if [ -d ".venv" ]; then
.venv/bin/python -m build
else
Expand All @@ -98,7 +119,7 @@ fi
echo -e "${GREEN}✓ Built${NC}"
echo ""

echo -e "${BLUE}Step 4: Checking package${NC}"
echo -e "${BLUE}Step 5: Checking package${NC}"
if [ -d ".venv" ]; then
.venv/bin/twine check dist/*
else
Expand Down Expand Up @@ -140,7 +161,7 @@ upload_to_repo() {
# Deploy based on selection
case $DEPLOY_TARGET in
1)
echo -e "${BLUE}Step 5: Deploying to TestPyPI${NC}"
echo -e "${BLUE}Step 6: Deploying to TestPyPI${NC}"
upload_to_repo "testpypi" "TestPyPI"
if [ $? -eq 0 ]; then
echo ""
Expand All @@ -153,7 +174,7 @@ case $DEPLOY_TARGET in
fi
;;
2)
echo -e "${BLUE}Step 5: Deploying to PyPI${NC}"
echo -e "${BLUE}Step 6: Deploying to PyPI${NC}"
echo -e "${YELLOW}⚠ This will publish to production PyPI!${NC}"
read -p "Are you sure? (yes/N) " -r
echo
Expand All @@ -178,7 +199,7 @@ case $DEPLOY_TARGET in
fi
;;
3)
echo -e "${BLUE}Step 5a: Deploying to TestPyPI${NC}"
echo -e "${BLUE}Step 6a: Deploying to TestPyPI${NC}"
upload_to_repo "testpypi" "TestPyPI"
if [ $? -ne 0 ]; then
exit 1
Expand All @@ -193,7 +214,7 @@ case $DEPLOY_TARGET in
echo

if [[ $REPLY == "yes" ]]; then
echo -e "${BLUE}Step 5b: Deploying to PyPI${NC}"
echo -e "${BLUE}Step 6b: Deploying to PyPI${NC}"
upload_to_repo "" "PyPI"
if [ $? -eq 0 ]; then
echo ""
Expand Down
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ dependencies = [
"colorama>=0.4.0",
]

[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"ruff>=0.6.0",
]

[project.urls]
Homepage = "https://github.com/SMTPBench/SMTPBench"
Documentation = "https://github.com/SMTPBench/SMTPBench#readme"
Expand All @@ -47,3 +53,13 @@ smtpbench = "smtpbench.cli:main"

[tool.setuptools]
packages = ["smtpbench"]

[tool.ruff]
target-version = "py38"
line-length = 100

[tool.ruff.lint]
# E/W: pycodestyle, F: pyflakes, I: import sorting, UP: pyupgrade.
# E501 (line length) is left to the formatter, which owns wrapping.
select = ["E", "F", "I", "W", "UP"]
ignore = ["E501"]
Loading
Loading