2 Commits
Author SHA1 Message Date
lyrathorpeandClaude Opus 4.8 df19c60b17 fix: relay raw SMTP bytes without decoding
Build and publish container / build (pull_request) Successful in 9m6s
send_message decoded the message body with utf-8/errors="replace",
corrupting 8-bit content before forwarding. Pass the raw bytes straight
to smtp.sendmail so the message is relayed unchanged.

Fixes #4

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:22:11 +01:00
lyrathorpeandClaude Opus 4.8 a29889b731 fix: correct POP3 RETR/TOP, static maildrop, and IMAP fetching
Fix several POP3/IMAP proxy correctness defects:

- RETR returned an empty body because fetch_message kept only top-level
  bytes from the imaplib FETCH response; extract the RFC822 literal from
  the response tuple instead.
- DELE marks were wiped mid-session because STAT/LIST/UIDL refreshed the
  mailbox and cleared the deleted set. Snapshot the UID list once at
  authentication and keep the maildrop static for the session lifetime.
- RETR/TOP output now normalises line endings to CRLF, byte-stuffs lines
  beginning with ".", and emits the terminating ".\r\n" per RFC 1939.
- STAT/LIST batch message sizes via a single threaded UID FETCH and the
  IMAP client now uses a 30s socket timeout, keeping blocking work off the
  event loop.
- Implement the POP3 TOP command (headers plus first n body lines).

Fixes #1
Fixes #2
Fixes #3
Fixes #5
Fixes #6

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:22:11 +01:00
11 changed files with 35 additions and 608 deletions
+12 -51
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-
- name: Install test dependencies
run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt
$RUNNER_OS-pip-
- name: Run unit tests
run: python -m pytest -q
@@ -153,54 +153,15 @@ jobs:
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
# Record the release: write the computed version into pyproject.toml, then
# commit and tag it, so the packaging metadata always matches the release
# instead of drifting behind it. The version is derived from commit
# messages and only known here, after the build, so it cannot be set by
# hand in the pull request that causes the release.
#
# Neither push re-triggers this workflow: it listens on main only for the
# image-affecting paths above, and pyproject.toml is not one of them. The
# chore(release) subject also produces no bump of its own on the next run.
- name: Record and tag the release
# Record the release as an annotated git tag so the next run computes the
# following version from it. This push does not re-trigger the workflow,
# which only listens on the main branch and pull requests.
- name: Tag the release
if: steps.version.outputs.release == 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
python - "$VERSION" <<'PY'
import pathlib
import re
import sys
version = sys.argv[1]
path = pathlib.Path("pyproject.toml")
text = path.read_text()
text, count = re.subn(
r'(?m)^version = ".*"$', f'version = "{version}"', text, count=1
)
if count != 1:
raise SystemExit("no version line found in pyproject.toml")
path.write_text(text)
PY
v="v${{ steps.version.outputs.version }}"
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.${REGISTRY}"
git add pyproject.toml
# The file may already carry this version, in which case there is
# nothing to commit and `git commit` would fail the job after the
# image has already been pushed.
if git diff --cached --quiet; then
echo "pyproject.toml is already at ${VERSION}"
else
git commit -m "chore(release): v${VERSION}"
# Push the branch before the tag. If main has moved on and this push
# is rejected, the job fails without having left a tag pointing at a
# commit that is not on main.
git push origin "HEAD:${GITHUB_REF_NAME}"
fi
git tag -a "v${VERSION}" -m "v${VERSION}"
git push origin "v${VERSION}"
git tag -a "$v" -m "$v"
git push origin "$v"
-7
View File
@@ -1,7 +0,0 @@
result
result-*
__pycache__/
*.pyc
.venv/
.pytest_cache/
*.egg-info/
+1 -6
View File
@@ -3,16 +3,11 @@ 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 --chown=appuser:appuser proxy_server.py ./
COPY proxy_server.py ./
EXPOSE 110 25
USER appuser
CMD ["python", "proxy_server.py"]
-74
View File
@@ -30,11 +30,6 @@ 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.
@@ -53,67 +48,6 @@ docker run --rm -p 110:110 -p 25:25 \
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`:
```bash
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.
```bash
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:
```nix
{
inputs.legacy-email-proxy.url = "git+https://code.emmathe.dev/lyrathorpe/legacy-email-proxy";
# optionally: inputs.legacy-email-proxy.inputs.nixpkgs.follows = "nixpkgs";
}
```
```nix
{
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:
@@ -129,11 +63,3 @@ 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`.
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1787135253,
"narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-54
View File
@@ -1,54 +0,0 @@
{
description = "Unauthenticated POP3/SMTP front end proxied to authenticated IMAPS/SMTPS backends";
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system});
in
{
overlays.default = final: _prev: {
legacy-email-proxy = final.callPackage ./package.nix { };
};
packages = forAllSystems (pkgs: rec {
legacy-email-proxy = pkgs.callPackage ./package.nix { };
default = legacy-email-proxy;
});
# The NixOS module with its package option pointed at this flake's build,
# so consumers need no overlay. Use nixosModules.legacy-email-proxy
# instead if you would rather apply overlays.default yourself.
nixosModules.default =
{ lib, pkgs, ... }:
{
imports = [ ./module.nix ];
services.legacy-email-proxy.package =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.legacy-email-proxy;
};
nixosModules.legacy-email-proxy = ./module.nix;
# The package builds only if the test suite passes, so this covers both.
checks = forAllSystems (pkgs: {
inherit (self.packages.${pkgs.system}) legacy-email-proxy;
});
devShells = forAllSystems (pkgs: {
default = pkgs.mkShellNoCC {
inputsFrom = [ self.packages.${pkgs.system}.legacy-email-proxy ];
packages = [ pkgs.python3Packages.pytest ];
};
});
formatter = forAllSystems (pkgs: pkgs.nixfmt-tree);
};
}
-129
View File
@@ -1,129 +0,0 @@
# NixOS module for legacy-email-proxy.
#
# Consumed either through this flake's nixosModules.default (which defaults the
# package to the flake's own build) or directly, alongside overlays.default.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.legacy-email-proxy;
renderValue =
value:
if lib.isBool value then
lib.boolToString value # the proxy accepts 1/true/yes/on
else
toString value;
environment = lib.mapAttrs (_name: renderValue) (lib.filterAttrs (_: v: v != null) cfg.settings);
# The default listeners are 110 and 25, so the service normally needs
# CAP_NET_BIND_SERVICE. Drop it when both are configured above 1024.
portOf =
name: default:
let
value = cfg.settings.${name} or default;
in
if lib.isInt value then value else lib.toInt (toString value);
needsPrivilegedPorts = portOf "POP3_BIND_PORT" 110 < 1024 || portOf "SMTP_BIND_PORT" 25 < 1024;
in
{
options.services.legacy-email-proxy = {
enable = lib.mkEnableOption "the legacy POP3/SMTP proxy";
package = lib.mkPackageOption pkgs "legacy-email-proxy" { };
settings = lib.mkOption {
type =
with lib.types;
attrsOf (
nullOr (oneOf [
bool
int
str
])
);
default = { };
example = lib.literalExpression ''
{
POP3_BIND_ADDR = "10.0.0.1";
BACKEND_IMAP_HOST = "imap.example.com";
BACKEND_IMAP_USER = "someone@example.com";
BACKEND_MUTATE = false;
}
'';
description = ''
Environment variables for the proxy, passed to the service as-is.
Booleans render as `true`/`false`, which the proxy accepts. The full
list of variables is in the project README.
Everything set here lands in the Nix store and is world-readable. Put
credentials in {option}`services.legacy-email-proxy.environmentFile`
instead.
'';
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/var/lib/legacy-email-proxy/backend.env";
description = ''
Path to a systemd `EnvironmentFile` holding the backend credentials
(`BACKEND_IMAP_PASS`, `BACKEND_SMTP_PASS` and anything else that should
not be in the Nix store). Read by systemd at start, not by the service
user, so it can be root-owned and mode 0600.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.legacy-email-proxy = {
description = "Legacy POP3/SMTP proxy to authenticated IMAPS/SMTPS backends";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = environment // {
PYTHONUNBUFFERED = "1";
};
serviceConfig = {
ExecStart = lib.getExe cfg.package;
Restart = "always";
RestartSec = 5;
DynamicUser = true;
AmbientCapabilities = lib.optional needsPrivilegedPorts "CAP_NET_BIND_SERVICE";
CapabilityBoundingSet = lib.optional needsPrivilegedPorts "CAP_NET_BIND_SERVICE";
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
# AF_NETLINK is not gratuitous: glibc's getaddrinfo opens a netlink
# socket to sort resolver results, and backends are reached by name.
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
"AF_NETLINK"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" ];
}
// lib.optionalAttrs (cfg.environmentFile != null) {
EnvironmentFile = cfg.environmentFile;
};
};
};
}
-40
View File
@@ -1,40 +0,0 @@
{
lib,
python3Packages,
}:
python3Packages.buildPythonApplication {
pname = "legacy-email-proxy";
inherit ((lib.importTOML ./pyproject.toml).project) version;
pyproject = true;
# Only the files that affect the build, so editing the README or the CI
# workflow does not invalidate it.
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./proxy_server.py
./pyproject.toml
./requirements.txt
./pytest.ini
./tests
];
};
build-system = [ python3Packages.setuptools ];
dependencies = [ python3Packages.aiosmtpd ];
nativeCheckInputs = [ python3Packages.pytestCheckHook ];
meta = {
description = "Unauthenticated POP3/SMTP front end proxied to authenticated IMAPS/SMTPS backends";
longDescription = ''
Exposes cleartext POP3 and SMTP to clients that cannot speak TLS or
modern authentication, and forwards them to an authenticated IMAPS/SMTPS
backend. The front-end listeners are unauthenticated by design and must
be confined to a trusted network.
'';
homepage = "https://code.emmathe.dev/lyrathorpe/legacy-email-proxy";
mainProgram = "legacy-email-proxy";
platforms = lib.platforms.unix;
};
}
+20 -37
View File
@@ -51,10 +51,6 @@ 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):
@@ -257,31 +253,30 @@ class POP3Session:
return await self.send_line("-ERR Unsupported command")
async def handle_user(self, args):
# 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
if len(args) != 1:
return await self.send_line("-ERR USER requires username")
self.username = args[0]
return await self.send_line("+OK")
async def handle_pass(self, args):
# Accept any password. See handle_user: client credentials are
# accepted but never used or validated.
self.password = args[0] if args else None
if len(args) != 1:
return await self.send_line("-ERR PASS requires password")
self.password = args[0]
await asyncio.to_thread(self.authenticate)
return await self.send_line("+OK User authenticated")
def authenticate(self):
"""Authenticate to the IMAP backend using the configured proxy credentials.
"""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")
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 = IMAPBackend(username, password)
backend.login()
self._imap = backend
# Snapshot the maildrop once; it stays static for the session lifetime
@@ -380,12 +375,9 @@ 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.
for uid in self.deleted:
await asyncio.to_thread(self._imap.mark_deleted, uid)
await asyncio.to_thread(self._imap.expunge)
self._imap.logout()
await self.send_line("+OK Goodbye")
@@ -458,17 +450,8 @@ async def main():
await pop3_server.serve_forever()
def run():
"""Run the proxy until interrupted.
Entry point for the ``legacy-email-proxy`` console script; ``main`` is a
coroutine and cannot be referenced directly by one.
"""
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Shutdown requested")
if __name__ == "__main__":
run()
-28
View File
@@ -1,28 +0,0 @@
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "legacy-email-proxy"
# Written by the release job in .gitea/workflows/build-and-publish.yaml, which
# derives the version from conventional commits and tags the result. Do not
# edit by hand: a hand-set value is overwritten at the next release.
version = "0.4.0"
description = "Unauthenticated POP3/SMTP front end proxied to authenticated IMAPS/SMTPS backends"
readme = "README.md"
requires-python = ">=3.12"
dynamic = ["dependencies"]
[project.scripts]
legacy-email-proxy = "proxy_server:run"
[project.urls]
Homepage = "https://code.emmathe.dev/lyrathorpe/legacy-email-proxy"
# requirements.txt stays the single source of runtime dependencies, so the
# Dockerfile and this file cannot drift apart.
[tool.setuptools.dynamic]
dependencies = { file = ["requirements.txt"] }
[tool.setuptools]
py-modules = ["proxy_server"]
+2 -155
View File
@@ -143,95 +143,6 @@ 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()
@@ -249,26 +160,6 @@ 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."""
@@ -336,49 +227,6 @@ 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"]))
@@ -387,7 +235,6 @@ 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 == []
assert session._imap.expunged is False
assert session._imap.marked == [b"2"]
assert session._imap.expunged is True