lyrathorpeandEmma Thorpe e2ec48f1db fix: keep the packaged version in step with the release tag (#17)
## The defect

`pyproject.toml` carried a static version while releases are derived from conventional commits and tagged by CI. The first release after the packaging change was cut as **v0.4.0** while the file still declared `0.3.0`, so both the Python package metadata and the Nix store path understated the release.

## Why a human cannot fix this by hand

The version is computed from commit messages *since the last tag* and is only known inside the release job, after the build. The pull request that causes a release cannot know the number it will produce — especially with more than one PR in flight. Any hand-set value drifts again at the next release.

## The fix

The release job now writes the computed version into `pyproject.toml`, commits it as `chore(release): vX.Y.Z`, and tags **that** commit.

- Neither push re-triggers the workflow: it listens on `main` only for the image-affecting paths, and `pyproject.toml` is not one of them.
- `chore(release)` produces no bump of its own on the next run.
- The branch push is ordered before the tag push, so a rejected push (main moved on) cannot leave a tag pointing at a commit that is not on `main`.
- `pyproject.toml` is bumped to `0.4.0` here to correct today's state; from the next release onward CI maintains it, and a comment in the file says so.

## Verification

- The version-rewrite step was extracted from the workflow and run against a copy of `pyproject.toml`: the line is rewritten, and the step raises if it ever finds no version line rather than silently doing nothing.
- `bash -n` on the step, `nix flake check` green at 0.4.0.
- `main` has no branch protection rules, so the job's push will be accepted. If you ever add protection, the CI token needs an exemption or this step fails.

---------

Co-authored-by: Emma Thorpe <emma.thorpe@citrix.com>
Reviewed-on: #17
2026-08-21 13:50:31 +01:00

Legacy Email Proxy

Proxy an unauthenticated, unencrypted POP3 / SMTP server to authenticated IMAPS and SMTPS backends.

Features

  • Exposes legacy POP3 on 0.0.0.0:110 and legacy SMTP on 0.0.0.0:25
  • Forwards POP3 mailbox access to an IMAP backend
  • Forwards SMTP submissions to an SMTPS backend
  • Backend host, ports, and credentials are configured via environment variables

Environment Variables

  • POP3_BIND_ADDR (default 0.0.0.0)

  • POP3_BIND_PORT (default 110)

  • SMTP_BIND_ADDR (default 0.0.0.0)

  • SMTP_BIND_PORT (default 25)

  • BACKEND_IMAP_HOST

  • BACKEND_IMAP_PORT (default 993)

  • BACKEND_IMAP_USER

  • BACKEND_IMAP_PASS

  • BACKEND_IMAP_USE_SSL (default true)

  • BACKEND_IMAP_USE_STARTTLS (default false)

  • BACKEND_SMTP_HOST

  • BACKEND_SMTP_PORT (default 465)

  • BACKEND_SMTP_USER

  • BACKEND_SMTP_PASS

  • BACKEND_SMTP_USE_SSL (default true)

  • BACKEND_SMTP_USE_TLS (default false)

  • BACKEND_MUTATE (default false) - when true, POP3 deletions are propagated to the backend IMAP server (STORE +FLAGS / EXPUNGE). Default behaviour is to never mutate the backend mailbox on POP client deletions; the proxy only hides messages for the duration of the client session.

Build and run

This project targets the latest Python LTS release. The included Dockerfile uses python:3.12-slim, which is compatible with Python 3.12 and later LTS releases.

docker build -t legacy-email-proxy .
docker run --rm -p 110:110 -p 25:25 \
  -e BACKEND_IMAP_HOST=imap.example.com \
  -e BACKEND_IMAP_PORT=993 \
  -e BACKEND_IMAP_USER=imap-user \
  -e BACKEND_IMAP_PASS=imap-pass \
  -e BACKEND_SMTP_HOST=smtp.example.com \
  -e BACKEND_SMTP_PORT=465 \
  -e BACKEND_SMTP_USER=smtp-user \
  -e BACKEND_SMTP_PASS=smtp-pass \
  legacy-email-proxy

The project is also a standard Python package (pyproject.toml), so it installs without Docker. This puts a legacy-email-proxy command on PATH:

pip install .
legacy-email-proxy

requirements.txt remains the single source of runtime dependencies; pyproject.toml reads it, so the Docker build and the package cannot drift.

Nix

The repository is a flake. It exposes the package, an overlay, and a NixOS module.

nix run .#legacy-email-proxy   # run it
nix build .#legacy-email-proxy # build it; the test suite runs as part of the build
nix develop                    # dev shell with pytest

As a NixOS service, with the proxy's own flake as an input:

{
  inputs.legacy-email-proxy.url = "git+https://code.emmathe.dev/lyrathorpe/legacy-email-proxy";
  # optionally: inputs.legacy-email-proxy.inputs.nixpkgs.follows = "nixpkgs";
}
{
  imports = [ inputs.legacy-email-proxy.nixosModules.default ];

  services.legacy-email-proxy = {
    enable = true;
    settings = {
      POP3_BIND_ADDR = "10.0.0.1";
      SMTP_BIND_ADDR = "10.0.0.1";
      BACKEND_IMAP_HOST = "imap.example.com";
      BACKEND_IMAP_USER = "someone@example.com";
      BACKEND_SMTP_HOST = "smtp.example.com";
      BACKEND_SMTP_USER = "someone@example.com";
    };
    # Credentials belong here, not in `settings` -- anything in `settings`
    # lands in the world-readable Nix store.
    environmentFile = "/var/lib/legacy-email-proxy/backend.env";
  };
}

settings accepts any of the environment variables listed above; booleans and integers are converted for you. The service runs under DynamicUser, with CAP_NET_BIND_SERVICE granted only while a listener is on a privileged port. It opens no firewall ports — see "Security".

Prefer to manage the package yourself? overlays.default provides pkgs.legacy-email-proxy, and nixosModules.legacy-email-proxy is the same module without the package default wired to this flake.

Tests

Install development dependencies and run the test suite:

python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
pytest -q

Notes

This implementation begins the proxy with a minimal POP3 command set and SMTP delivery path. It is designed to start development on the required application architecture.

Security

By design, the front-end POP3 (port 110) and SMTP (port 25) listeners are unencrypted and unauthenticated. Anyone who can reach port 110 obtains full mailbox access, and anyone who can reach port 25 can relay mail through the configured backend SMTP credentials, which is an open relay from the network's perspective.

Because of this, the listeners must be bound to a trusted internal network only, such as a private Docker bridge, a VPN interface, or localhost, and must not be exposed to untrusted networks or the public internet.

Operators who need to restrict the bind address can set POP3_BIND_ADDR / SMTP_BIND_ADDR to a specific internal interface instead of 0.0.0.0.

S
Description
No description provided
Readme
128 KiB
Languages
Python 80.5%
Nix 18.5%
Dockerfile 1%