6 Commits

Author SHA1 Message Date
lyrathorpe 4bde4f884d feat: respect BACKEND_MUTATE to avoid mutating backend mailboxes by default; add tests and docs
Build and publish container / build (push) Successful in 7m8s
2026-06-17 18:43:22 +01:00
lyrathorpe 9a4bab33e2 feat: ignore client-supplied POP3 credentials (#15)
Build and publish container / build (push) Successful in 12m15s
Accept any POP3 `USER`/`PASS` from the client and discard them. The proxy always authenticates to the IMAP backend with the configured `BACKEND_IMAP_USER` / `BACKEND_IMAP_PASS`.

## Changes

- `handle_user` / `handle_pass`: accept client credentials unconditionally, no validation.
- `authenticate`: always use backend credentials; remove the fallback that connected with client-supplied credentials when backend credentials were unset. Raise a clear configuration error when backend credentials are missing.
- Tests: client credentials are ignored; missing backend credentials are reported.

Closes #14

---------

Co-authored-by: Emma Thorpe <emma.thorpe@citrix.com>
Reviewed-on: #15
2026-06-17 18:16:37 +01:00
lyrathorpe e51740b8db Document unauthenticated listener exposure (#13)
Adds a Security section: the front-end POP3/SMTP listeners are unauthenticated and must be bound to a trusted internal network only. Closes #9

Reviewed-on: #13
Co-authored-by: Lyra Thorpe <iam@emmathe.dev>
Co-committed-by: Lyra Thorpe <iam@emmathe.dev>
2026-06-17 17:33:51 +01:00
lyrathorpe 4ab12f8ce6 Cache pip before install and fix cache key expansion (#12)
Moves the actions/cache step ahead of the install step and uses the runner.os Actions expression in the cache key. Closes #8

Reviewed-on: #12
Co-authored-by: Lyra Thorpe <iam@emmathe.dev>
Co-committed-by: Lyra Thorpe <iam@emmathe.dev>
2026-06-17 17:33:37 +01:00
lyrathorpe 7930235efd Run container as non-root user (#11)
Build and publish container / build (push) Successful in 5m59s
Adds a dedicated non-root user and switches to it before CMD. Verified the container runs as a non-root uid. Closes #7

Reviewed-on: #11
Co-authored-by: Lyra Thorpe <iam@emmathe.dev>
Co-committed-by: Lyra Thorpe <iam@emmathe.dev>
2026-06-17 17:33:23 +01:00
lyrathorpe bde999185a Fix POP3/SMTP correctness and IMAP robustness (#10)
Build and publish container / build (push) Has been cancelled
POP3/SMTP proxy correctness fixes.

- RETR now returns the message body (reads the FETCH tuple). Closes #1
- Maildrop snapshotted once; DELE marks survive STAT/LIST/UIDL. Closes #2
- RETR/TOP dot-stuff and CRLF-terminate per RFC 1939. Closes #3
- SMTP relays raw bytes, no UTF-8 mangling. Closes #4
- Message sizes batched in one IMAP round-trip off the event loop; IMAP timeout added. Closes #5
- POP3 TOP implemented. Closes #6

pytest: 10 passed.
Reviewed-on: #10
Co-authored-by: Lyra Thorpe <iam@emmathe.dev>
Co-committed-by: Lyra Thorpe <iam@emmathe.dev>
2026-06-17 17:33:09 +01:00
5 changed files with 206 additions and 27 deletions
+5 -5
View File
@@ -45,16 +45,16 @@ jobs:
with:
python-version: 3.12
- name: Install test dependencies
run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: "$RUNNER_OS-pip-${{ hashFiles('requirements-dev.txt') }}"
key: "${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}"
restore-keys: |
$RUNNER_OS-pip-
${{ runner.os }}-pip-
- name: Install test dependencies
run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt
- name: Run unit tests
run: python -m pytest -q
+6 -1
View File
@@ -3,11 +3,16 @@ FROM python:3.12-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
# Create a dedicated non-root user and group to run the proxy.
RUN groupadd --system appuser && useradd --system --gid appuser appuser
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY proxy_server.py ./
COPY --chown=appuser:appuser proxy_server.py ./
EXPOSE 110 25
USER appuser
CMD ["python", "proxy_server.py"]
+13
View File
@@ -30,6 +30,11 @@ Proxy an unauthenticated, unencrypted POP3 / SMTP server to authenticated IMAPS
- `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.
@@ -63,3 +68,11 @@ 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`.
+24 -16
View File
@@ -51,6 +51,10 @@ class Settings:
BACKEND_SMTP_PASS = os.getenv("BACKEND_SMTP_PASS")
BACKEND_SMTP_USE_SSL = env_bool("BACKEND_SMTP_USE_SSL", True)
BACKEND_SMTP_USE_TLS = env_bool("BACKEND_SMTP_USE_TLS", False)
# When false (default) the proxy will not mutate backend mailboxes
# (no STORE +FLAGS / EXPUNGE). Set to true only when deletions should
# be propagated to the backend IMAP server.
BACKEND_MUTATE = env_bool("BACKEND_MUTATE", False)
@classmethod
def validate(cls):
@@ -253,30 +257,31 @@ class POP3Session:
return await self.send_line("-ERR Unsupported command")
async def handle_user(self, args):
if len(args) != 1:
return await self.send_line("-ERR USER requires username")
self.username = args[0]
# Accept any username. Client credentials are intentionally ignored;
# some legacy clients insist on supplying them, so they are accepted
# blindly. The backend is always reached with the proxy's own creds.
self.username = args[0] if args else None
return await self.send_line("+OK")
async def handle_pass(self, args):
if len(args) != 1:
return await self.send_line("-ERR PASS requires password")
self.password = args[0]
# Accept any password. See handle_user: client credentials are
# accepted but never used or validated.
self.password = args[0] if args else None
await asyncio.to_thread(self.authenticate)
return await self.send_line("+OK User authenticated")
def authenticate(self):
"""Authenticate to the IMAP backend using configured credentials."""
if Settings.BACKEND_IMAP_USER and Settings.BACKEND_IMAP_PASS:
username = Settings.BACKEND_IMAP_USER
password = Settings.BACKEND_IMAP_PASS
elif self.username and self.password:
username = self.username
password = self.password
else:
raise RuntimeError("No IMAP credentials available")
"""Authenticate to the IMAP backend using the configured proxy credentials.
backend = IMAPBackend(username, password)
Client-supplied POP3 credentials are deliberately ignored: the proxy
always connects to the backend with ``BACKEND_IMAP_USER`` /
``BACKEND_IMAP_PASS``. This is by design for legacy clients that require
credentials to be entered even though the proxy does not use them.
"""
if not (Settings.BACKEND_IMAP_USER and Settings.BACKEND_IMAP_PASS):
raise RuntimeError("Backend IMAP credentials are not configured")
backend = IMAPBackend(Settings.BACKEND_IMAP_USER, Settings.BACKEND_IMAP_PASS)
backend.login()
self._imap = backend
# Snapshot the maildrop once; it stays static for the session lifetime
@@ -375,9 +380,12 @@ class POP3Session:
async def handle_quit(self):
if self._imap:
# Only propagate deletes to the backend when explicitly enabled.
if Settings.BACKEND_MUTATE:
for uid in self.deleted:
await asyncio.to_thread(self._imap.mark_deleted, uid)
await asyncio.to_thread(self._imap.expunge)
# Always logout the backend connection.
self._imap.logout()
await self.send_line("+OK Goodbye")
+155 -2
View File
@@ -143,6 +143,95 @@ def test_smtp_proxy_handler_forwards_message_over_ssl(monkeypatch):
Settings.BACKEND_SMTP_PASS = previous_pass
def test_pop3_quit_does_not_mutate_backend_by_default():
import asyncio
class DummyBackend:
def __init__(self):
self.deleted_called = []
self.expunge_called = False
self.logged_out = False
def mark_deleted(self, uid):
self.deleted_called.append(uid)
def expunge(self):
self.expunge_called = True
def logout(self):
self.logged_out = True
session = POP3Session(None, None)
backend = DummyBackend()
session._imap = backend
session.deleted = {b"1", b"2"}
# Provide a dummy writer so send_line can be awaited without a socket.
class DummyWriter:
def __init__(self):
self.buf = b""
self.closed = False
def write(self, data):
self.buf += data
async def drain(self):
return None
def close(self):
self.closed = True
async def wait_closed(self):
return None
session.writer = DummyWriter()
# Ensure default is not to mutate
previous_mutate = Settings.BACKEND_MUTATE
Settings.BACKEND_MUTATE = False
asyncio.run(session.handle_quit())
Settings.BACKEND_MUTATE = previous_mutate
assert backend.deleted_called == []
assert backend.expunge_called is False
assert backend.logged_out is True
def test_pop3_quit_mutates_backend_when_enabled():
import asyncio
class DummyBackend:
def __init__(self):
self.deleted_called = []
self.expunge_called = False
self.logged_out = False
def mark_deleted(self, uid):
self.deleted_called.append(uid)
def expunge(self):
self.expunge_called = True
def logout(self):
self.logged_out = True
session = POP3Session(None, None)
backend = DummyBackend()
session._imap = backend
session.deleted = {b"1", b"2"}
session.writer = DummyWriter()
previous_mutate = Settings.BACKEND_MUTATE
Settings.BACKEND_MUTATE = True
asyncio.run(session.handle_quit())
Settings.BACKEND_MUTATE = previous_mutate
assert set(backend.deleted_called) == {b"1", b"2"}
assert backend.expunge_called is True
assert backend.logged_out is True
class FakeWriter:
def __init__(self):
self.buffer = bytearray()
@@ -160,6 +249,26 @@ class FakeWriter:
pass
class DummyWriter:
"""Simple writer used by tests where we only need write/drain/close."""
def __init__(self):
self.buf = b""
self.closed = False
def write(self, data):
self.buf += data
async def drain(self):
return None
def close(self):
self.closed = True
async def wait_closed(self):
return None
class RecordingIMAP:
"""In-memory IMAPBackend stand-in for POP3 session tests."""
@@ -227,6 +336,49 @@ def test_top_returns_headers_and_limited_body():
assert output.endswith(b".\r\n")
def test_authenticate_ignores_client_credentials(monkeypatch):
captured = {}
def fake_login(self):
captured["username"] = self.username
captured["password"] = self.password
monkeypatch.setattr(IMAPBackend, "login", fake_login)
monkeypatch.setattr(IMAPBackend, "list_uids", lambda self: [])
previous_user = Settings.BACKEND_IMAP_USER
previous_pass = Settings.BACKEND_IMAP_PASS
Settings.BACKEND_IMAP_USER = "backend-user"
Settings.BACKEND_IMAP_PASS = "backend-pass"
session = POP3Session(None, FakeWriter())
session.username = "client-user"
session.password = "client-pass"
session.authenticate()
# The proxy must connect with its own credentials, never the client's.
assert captured["username"] == "backend-user"
assert captured["password"] == "backend-pass"
Settings.BACKEND_IMAP_USER = previous_user
Settings.BACKEND_IMAP_PASS = previous_pass
def test_authenticate_requires_backend_credentials(monkeypatch):
previous_user = Settings.BACKEND_IMAP_USER
previous_pass = Settings.BACKEND_IMAP_PASS
Settings.BACKEND_IMAP_USER = None
Settings.BACKEND_IMAP_PASS = None
session = POP3Session(None, FakeWriter())
session.username = "client-user"
session.password = "client-pass"
with pytest.raises(RuntimeError, match="Backend IMAP credentials are not configured"):
session.authenticate()
Settings.BACKEND_IMAP_USER = previous_user
Settings.BACKEND_IMAP_PASS = previous_pass
def test_dele_survives_stat_list_uidl_until_quit():
session = make_session([b"1", b"2", b"3"])
asyncio.run(session.handle_dele(["2"]))
@@ -235,6 +387,7 @@ def test_dele_survives_stat_list_uidl_until_quit():
asyncio.run(session.handle_list([]))
asyncio.run(session.handle_uidl([]))
assert b"2" in session.deleted
# Default behaviour is not to mutate the backend mailbox on QUIT.
asyncio.run(session.handle_quit())
assert session._imap.marked == [b"2"]
assert session._imap.expunged is True
assert session._imap.marked == []
assert session._imap.expunged is False