Compare commits
6 Commits
df19c60b17
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bde4f884d | |||
| 9a4bab33e2 | |||
| e51740b8db | |||
| 4ab12f8ce6 | |||
| 7930235efd | |||
| bde999185a |
@@ -45,16 +45,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: 3.12
|
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
|
- name: Cache pip dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
key: "$RUNNER_OS-pip-${{ hashFiles('requirements-dev.txt') }}"
|
key: "${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}"
|
||||||
restore-keys: |
|
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
|
- name: Run unit tests
|
||||||
run: python -m pytest -q
|
run: python -m pytest -q
|
||||||
|
|||||||
+6
-1
@@ -3,11 +3,16 @@ FROM python:3.12-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PYTHONUNBUFFERED=1
|
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 ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r 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
|
EXPOSE 110 25
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
CMD ["python", "proxy_server.py"]
|
CMD ["python", "proxy_server.py"]
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ Proxy an unauthenticated, unencrypted POP3 / SMTP server to authenticated IMAPS
|
|||||||
- `BACKEND_SMTP_USE_SSL` (default `true`)
|
- `BACKEND_SMTP_USE_SSL` (default `true`)
|
||||||
- `BACKEND_SMTP_USE_TLS` (default `false`)
|
- `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
|
## 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.
|
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
|
## 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.
|
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`.
|
||||||
|
|||||||
+114
-37
@@ -51,6 +51,10 @@ class Settings:
|
|||||||
BACKEND_SMTP_PASS = os.getenv("BACKEND_SMTP_PASS")
|
BACKEND_SMTP_PASS = os.getenv("BACKEND_SMTP_PASS")
|
||||||
BACKEND_SMTP_USE_SSL = env_bool("BACKEND_SMTP_USE_SSL", True)
|
BACKEND_SMTP_USE_SSL = env_bool("BACKEND_SMTP_USE_SSL", True)
|
||||||
BACKEND_SMTP_USE_TLS = env_bool("BACKEND_SMTP_USE_TLS", False)
|
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
|
@classmethod
|
||||||
def validate(cls):
|
def validate(cls):
|
||||||
@@ -64,6 +68,41 @@ class Settings:
|
|||||||
raise RuntimeError(f"Missing required environment variables: {', '.join(missing)}")
|
raise RuntimeError(f"Missing required environment variables: {', '.join(missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_lines(data):
|
||||||
|
"""Split a byte string into lines, normalising CR/CRLF/LF endings."""
|
||||||
|
text = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
||||||
|
lines = text.split(b"\n")
|
||||||
|
if lines and lines[-1] == b"":
|
||||||
|
lines = lines[:-1]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def format_pop3_body(body, max_body_lines=None):
|
||||||
|
"""Prepare a message for POP3 RETR/TOP transmission (RFC 1939).
|
||||||
|
|
||||||
|
Normalises line endings to CRLF, byte-stuffs any line beginning with ".",
|
||||||
|
and appends the terminating ".\\r\\n" on its own line. When ``max_body_lines``
|
||||||
|
is given, the full header block is kept and the body is truncated to that
|
||||||
|
many lines (TOP semantics).
|
||||||
|
"""
|
||||||
|
lines = _split_lines(body)
|
||||||
|
if max_body_lines is not None:
|
||||||
|
try:
|
||||||
|
separator = lines.index(b"")
|
||||||
|
except ValueError:
|
||||||
|
separator = len(lines)
|
||||||
|
header_lines = lines[: separator + 1]
|
||||||
|
body_lines = lines[separator + 1 :][:max_body_lines]
|
||||||
|
lines = header_lines + body_lines
|
||||||
|
stuffed = []
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith(b"."):
|
||||||
|
line = b"." + line
|
||||||
|
stuffed.append(line)
|
||||||
|
stuffed.append(b".")
|
||||||
|
return b"\r\n".join(stuffed) + b"\r\n"
|
||||||
|
|
||||||
|
|
||||||
class IMAPBackend:
|
class IMAPBackend:
|
||||||
"""Minimal IMAP wrapper for backend mailbox access."""
|
"""Minimal IMAP wrapper for backend mailbox access."""
|
||||||
|
|
||||||
@@ -76,9 +115,13 @@ class IMAPBackend:
|
|||||||
|
|
||||||
def _connect(self):
|
def _connect(self):
|
||||||
if Settings.BACKEND_IMAP_USE_SSL:
|
if Settings.BACKEND_IMAP_USE_SSL:
|
||||||
client = imaplib.IMAP4_SSL(Settings.BACKEND_IMAP_HOST, Settings.BACKEND_IMAP_PORT)
|
client = imaplib.IMAP4_SSL(
|
||||||
|
Settings.BACKEND_IMAP_HOST, Settings.BACKEND_IMAP_PORT, timeout=30
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
client = imaplib.IMAP4(Settings.BACKEND_IMAP_HOST, Settings.BACKEND_IMAP_PORT)
|
client = imaplib.IMAP4(
|
||||||
|
Settings.BACKEND_IMAP_HOST, Settings.BACKEND_IMAP_PORT, timeout=30
|
||||||
|
)
|
||||||
if Settings.BACKEND_IMAP_USE_STARTTLS:
|
if Settings.BACKEND_IMAP_USE_STARTTLS:
|
||||||
client.starttls(ssl_context=ssl.create_default_context())
|
client.starttls(ssl_context=ssl.create_default_context())
|
||||||
return client
|
return client
|
||||||
@@ -116,12 +159,30 @@ class IMAPBackend:
|
|||||||
match = re.search(rb"RFC822\.SIZE (\d+)", response)
|
match = re.search(rb"RFC822\.SIZE (\d+)", response)
|
||||||
return int(match.group(1)) if match else 0
|
return int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
def fetch_all_sizes(self):
|
||||||
|
"""Return a {uid: size} mapping for all messages in one IMAP round-trip."""
|
||||||
|
sizes = {}
|
||||||
|
typ, data = self.connection.uid("fetch", "1:*", "(RFC822.SIZE)")
|
||||||
|
if typ != "OK" or not data:
|
||||||
|
return sizes
|
||||||
|
for response in data:
|
||||||
|
if isinstance(response, tuple):
|
||||||
|
response = response[0]
|
||||||
|
if not isinstance(response, bytes):
|
||||||
|
continue
|
||||||
|
match = self.UID_PATTERN.search(response)
|
||||||
|
if match:
|
||||||
|
sizes[match.group(1)] = int(match.group(2))
|
||||||
|
return sizes
|
||||||
|
|
||||||
def fetch_message(self, uid):
|
def fetch_message(self, uid):
|
||||||
typ, data = self.connection.uid("fetch", uid, "(RFC822)")
|
typ, data = self.connection.uid("fetch", uid, "(RFC822)")
|
||||||
if typ != "OK" or not data:
|
if typ != "OK" or not data:
|
||||||
raise RuntimeError("IMAP message fetch failed")
|
raise RuntimeError("IMAP message fetch failed")
|
||||||
parts = [chunk for chunk in data if isinstance(chunk, bytes)]
|
for chunk in data:
|
||||||
return b"\r\n".join(parts) + b"\r\n"
|
if isinstance(chunk, tuple) and len(chunk) > 1:
|
||||||
|
return chunk[1]
|
||||||
|
raise RuntimeError("IMAP message fetch returned no body")
|
||||||
|
|
||||||
def mark_deleted(self, uid):
|
def mark_deleted(self, uid):
|
||||||
self.connection.uid("STORE", uid, "+FLAGS.SILENT", "(\\Deleted)")
|
self.connection.uid("STORE", uid, "+FLAGS.SILENT", "(\\Deleted)")
|
||||||
@@ -179,6 +240,8 @@ class POP3Session:
|
|||||||
return await self.handle_list(args)
|
return await self.handle_list(args)
|
||||||
if command == "RETR":
|
if command == "RETR":
|
||||||
return await self.handle_retr(args)
|
return await self.handle_retr(args)
|
||||||
|
if command == "TOP":
|
||||||
|
return await self.handle_top(args)
|
||||||
if command == "DELE":
|
if command == "DELE":
|
||||||
return await self.handle_dele(args)
|
return await self.handle_dele(args)
|
||||||
if command == "NOOP":
|
if command == "NOOP":
|
||||||
@@ -194,55 +257,53 @@ class POP3Session:
|
|||||||
return await self.send_line("-ERR Unsupported command")
|
return await self.send_line("-ERR Unsupported command")
|
||||||
|
|
||||||
async def handle_user(self, args):
|
async def handle_user(self, args):
|
||||||
if len(args) != 1:
|
# Accept any username. Client credentials are intentionally ignored;
|
||||||
return await self.send_line("-ERR USER requires username")
|
# some legacy clients insist on supplying them, so they are accepted
|
||||||
self.username = args[0]
|
# 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")
|
return await self.send_line("+OK")
|
||||||
|
|
||||||
async def handle_pass(self, args):
|
async def handle_pass(self, args):
|
||||||
if len(args) != 1:
|
# Accept any password. See handle_user: client credentials are
|
||||||
return await self.send_line("-ERR PASS requires password")
|
# accepted but never used or validated.
|
||||||
self.password = args[0]
|
self.password = args[0] if args else None
|
||||||
await asyncio.to_thread(self.authenticate)
|
await asyncio.to_thread(self.authenticate)
|
||||||
return await self.send_line("+OK User authenticated")
|
return await self.send_line("+OK User authenticated")
|
||||||
|
|
||||||
def authenticate(self):
|
def authenticate(self):
|
||||||
"""Authenticate to the IMAP backend using configured credentials."""
|
"""Authenticate to the IMAP backend using the configured proxy 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")
|
|
||||||
|
|
||||||
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()
|
backend.login()
|
||||||
self._imap = backend
|
self._imap = backend
|
||||||
self._refresh_mailbox()
|
# Snapshot the maildrop once; it stays static for the session lifetime
|
||||||
|
# (RFC 1939), so DELE marks are not wiped by later STAT/LIST/UIDL.
|
||||||
def _refresh_mailbox(self):
|
self.message_ids = backend.list_uids()
|
||||||
self.message_ids = self._imap.list_uids()
|
|
||||||
self.deleted.clear()
|
|
||||||
|
|
||||||
async def handle_stat(self):
|
async def handle_stat(self):
|
||||||
self._require_auth()
|
self._require_auth()
|
||||||
self._refresh_mailbox()
|
sizes = await asyncio.to_thread(self._imap.fetch_all_sizes)
|
||||||
count = len(self.message_ids) - len(self.deleted)
|
count = len(self.message_ids) - len(self.deleted)
|
||||||
total_size = sum(self._imap.fetch_message_size(uid) for uid in self.message_ids if uid not in self.deleted)
|
total_size = sum(sizes.get(uid, 0) for uid in self.message_ids if uid not in self.deleted)
|
||||||
return await self.send_line(f"+OK {count} {total_size}")
|
return await self.send_line(f"+OK {count} {total_size}")
|
||||||
|
|
||||||
async def handle_list(self, args):
|
async def handle_list(self, args):
|
||||||
self._require_auth()
|
self._require_auth()
|
||||||
self._refresh_mailbox()
|
sizes = await asyncio.to_thread(self._imap.fetch_all_sizes)
|
||||||
if not args:
|
if not args:
|
||||||
lines = [f"+OK {len(self.message_ids)} messages"]
|
lines = [f"+OK {len(self.message_ids)} messages"]
|
||||||
for index, uid in enumerate(self.message_ids, start=1):
|
for index, uid in enumerate(self.message_ids, start=1):
|
||||||
if uid in self.deleted:
|
if uid in self.deleted:
|
||||||
continue
|
continue
|
||||||
size = self._imap.fetch_message_size(uid)
|
lines.append(f"{index} {sizes.get(uid, 0)}")
|
||||||
lines.append(f"{index} {size}")
|
|
||||||
lines.append(".")
|
lines.append(".")
|
||||||
return await self.send_lines(lines)
|
return await self.send_lines(lines)
|
||||||
if len(args) != 1 or not args[0].isdigit():
|
if len(args) != 1 or not args[0].isdigit():
|
||||||
@@ -253,8 +314,7 @@ class POP3Session:
|
|||||||
uid = self.message_ids[message_number - 1]
|
uid = self.message_ids[message_number - 1]
|
||||||
if uid in self.deleted:
|
if uid in self.deleted:
|
||||||
return await self.send_line("-ERR message deleted")
|
return await self.send_line("-ERR message deleted")
|
||||||
size = self._imap.fetch_message_size(uid)
|
return await self.send_line(f"+OK {message_number} {sizes.get(uid, 0)}")
|
||||||
return await self.send_line(f"+OK {message_number} {size}")
|
|
||||||
|
|
||||||
async def handle_retr(self, args):
|
async def handle_retr(self, args):
|
||||||
self._require_auth()
|
self._require_auth()
|
||||||
@@ -267,9 +327,25 @@ class POP3Session:
|
|||||||
if uid in self.deleted:
|
if uid in self.deleted:
|
||||||
return await self.send_line("-ERR message deleted")
|
return await self.send_line("-ERR message deleted")
|
||||||
message = await asyncio.to_thread(self._imap.fetch_message, uid)
|
message = await asyncio.to_thread(self._imap.fetch_message, uid)
|
||||||
|
payload = format_pop3_body(message)
|
||||||
await self.send_line(f"+OK {len(message)} octets")
|
await self.send_line(f"+OK {len(message)} octets")
|
||||||
await self.send_raw(message)
|
await self.send_raw(payload)
|
||||||
await self.send_line(".")
|
|
||||||
|
async def handle_top(self, args):
|
||||||
|
self._require_auth()
|
||||||
|
if len(args) != 2 or not args[0].isdigit() or not args[1].isdigit():
|
||||||
|
return await self.send_line("-ERR TOP requires message number and line count")
|
||||||
|
index = int(args[0])
|
||||||
|
line_count = int(args[1])
|
||||||
|
if index < 1 or index > len(self.message_ids):
|
||||||
|
return await self.send_line("-ERR no such message")
|
||||||
|
uid = self.message_ids[index - 1]
|
||||||
|
if uid in self.deleted:
|
||||||
|
return await self.send_line("-ERR message deleted")
|
||||||
|
message = await asyncio.to_thread(self._imap.fetch_message, uid)
|
||||||
|
payload = format_pop3_body(message, max_body_lines=line_count)
|
||||||
|
await self.send_line("+OK top of message follows")
|
||||||
|
await self.send_raw(payload)
|
||||||
|
|
||||||
async def handle_dele(self, args):
|
async def handle_dele(self, args):
|
||||||
self._require_auth()
|
self._require_auth()
|
||||||
@@ -284,7 +360,6 @@ class POP3Session:
|
|||||||
|
|
||||||
async def handle_uidl(self, args):
|
async def handle_uidl(self, args):
|
||||||
self._require_auth()
|
self._require_auth()
|
||||||
self._refresh_mailbox()
|
|
||||||
if not args:
|
if not args:
|
||||||
lines = ["+OK UID list follows"]
|
lines = ["+OK UID list follows"]
|
||||||
for index, uid in enumerate(self.message_ids, start=1):
|
for index, uid in enumerate(self.message_ids, start=1):
|
||||||
@@ -305,9 +380,12 @@ class POP3Session:
|
|||||||
|
|
||||||
async def handle_quit(self):
|
async def handle_quit(self):
|
||||||
if self._imap:
|
if self._imap:
|
||||||
|
# Only propagate deletes to the backend when explicitly enabled.
|
||||||
|
if Settings.BACKEND_MUTATE:
|
||||||
for uid in self.deleted:
|
for uid in self.deleted:
|
||||||
await asyncio.to_thread(self._imap.mark_deleted, uid)
|
await asyncio.to_thread(self._imap.mark_deleted, uid)
|
||||||
await asyncio.to_thread(self._imap.expunge)
|
await asyncio.to_thread(self._imap.expunge)
|
||||||
|
# Always logout the backend connection.
|
||||||
self._imap.logout()
|
self._imap.logout()
|
||||||
await self.send_line("+OK Goodbye")
|
await self.send_line("+OK Goodbye")
|
||||||
|
|
||||||
@@ -346,7 +424,6 @@ class SMTPProxyHandler:
|
|||||||
|
|
||||||
def send_message(self, sender, recipients, data):
|
def send_message(self, sender, recipients, data):
|
||||||
"""Forward a complete SMTP message to the backend SMTP server."""
|
"""Forward a complete SMTP message to the backend SMTP server."""
|
||||||
message = data.decode("utf-8", errors="replace")
|
|
||||||
if Settings.BACKEND_SMTP_USE_SSL:
|
if Settings.BACKEND_SMTP_USE_SSL:
|
||||||
smtp = smtplib.SMTP_SSL(Settings.BACKEND_SMTP_HOST, Settings.BACKEND_SMTP_PORT, timeout=30)
|
smtp = smtplib.SMTP_SSL(Settings.BACKEND_SMTP_HOST, Settings.BACKEND_SMTP_PORT, timeout=30)
|
||||||
else:
|
else:
|
||||||
@@ -356,7 +433,7 @@ class SMTPProxyHandler:
|
|||||||
try:
|
try:
|
||||||
if Settings.BACKEND_SMTP_USER and Settings.BACKEND_SMTP_PASS:
|
if Settings.BACKEND_SMTP_USER and Settings.BACKEND_SMTP_PASS:
|
||||||
smtp.login(Settings.BACKEND_SMTP_USER, Settings.BACKEND_SMTP_PASS)
|
smtp.login(Settings.BACKEND_SMTP_USER, Settings.BACKEND_SMTP_PASS)
|
||||||
smtp.sendmail(sender, recipients, message)
|
smtp.sendmail(sender, recipients, data)
|
||||||
finally:
|
finally:
|
||||||
smtp.quit()
|
smtp.quit()
|
||||||
|
|
||||||
|
|||||||
+279
-7
@@ -1,15 +1,24 @@
|
|||||||
|
import asyncio
|
||||||
import imaplib
|
import imaplib
|
||||||
import smtplib
|
import smtplib
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from proxy_server import IMAPBackend, SMTPProxyHandler, Settings, env_bool
|
from proxy_server import (
|
||||||
|
IMAPBackend,
|
||||||
|
POP3Session,
|
||||||
|
SMTPProxyHandler,
|
||||||
|
Settings,
|
||||||
|
env_bool,
|
||||||
|
format_pop3_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DummyIMAP:
|
class DummyIMAP:
|
||||||
def __init__(self, host, port):
|
def __init__(self, host, port, timeout=0):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
|
self.timeout = timeout
|
||||||
self.logged_in = False
|
self.logged_in = False
|
||||||
self.selected = None
|
self.selected = None
|
||||||
|
|
||||||
@@ -23,9 +32,16 @@ class DummyIMAP:
|
|||||||
if command == "search":
|
if command == "search":
|
||||||
return "OK", [b"1 2 3"]
|
return "OK", [b"1 2 3"]
|
||||||
if command == "fetch" and args[1] == "(RFC822.SIZE)":
|
if command == "fetch" and args[1] == "(RFC822.SIZE)":
|
||||||
|
if args[0] == "1:*":
|
||||||
|
return "OK", [
|
||||||
|
b"1 (UID 1 RFC822.SIZE 1024)",
|
||||||
|
b"2 (UID 2 RFC822.SIZE 2048)",
|
||||||
|
b"3 (UID 3 RFC822.SIZE 512)",
|
||||||
|
]
|
||||||
return "OK", [(b"1 (RFC822.SIZE 1024)", b"")]
|
return "OK", [(b"1 (RFC822.SIZE 1024)", b"")]
|
||||||
if command == "fetch" and args[1] == "(RFC822)":
|
if command == "fetch" and args[1] == "(RFC822)":
|
||||||
return "OK", [b"1 (RFC822 {10}", b"Hello", b" World", b")"]
|
body = b"Subject: Hi\r\n\r\nHello World\r\n"
|
||||||
|
return "OK", [(b"1 (RFC822 {%d}" % len(body), body), b")"]
|
||||||
return "NO", []
|
return "NO", []
|
||||||
|
|
||||||
def logout(self):
|
def logout(self):
|
||||||
@@ -68,14 +84,17 @@ def test_settings_validate_succeeds_with_backends():
|
|||||||
|
|
||||||
|
|
||||||
def test_imap_backend_can_login_and_fetch(monkeypatch):
|
def test_imap_backend_can_login_and_fetch(monkeypatch):
|
||||||
monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port: DummyIMAP(host, port))
|
monkeypatch.setattr(
|
||||||
|
imaplib, "IMAP4_SSL", lambda host, port, timeout=0: DummyIMAP(host, port, timeout)
|
||||||
|
)
|
||||||
backend = IMAPBackend("user", "pass")
|
backend = IMAPBackend("user", "pass")
|
||||||
backend.login()
|
backend.login()
|
||||||
assert backend.connection.logged_in
|
assert backend.connection.logged_in
|
||||||
assert backend.connection.selected == "INBOX"
|
assert backend.connection.selected == "INBOX"
|
||||||
assert backend.list_uids() == [b"1", b"2", b"3"]
|
assert backend.list_uids() == [b"1", b"2", b"3"]
|
||||||
assert backend.fetch_message_size(b"1") == 1024
|
assert backend.fetch_message_size(b"1") == 1024
|
||||||
assert b"Hello" in backend.fetch_message(b"1")
|
assert backend.fetch_all_sizes() == {b"1": 1024, b"2": 2048, b"3": 512}
|
||||||
|
assert b"Hello World" in backend.fetch_message(b"1")
|
||||||
|
|
||||||
|
|
||||||
def test_smtp_proxy_handler_forwards_message_over_ssl(monkeypatch):
|
def test_smtp_proxy_handler_forwards_message_over_ssl(monkeypatch):
|
||||||
@@ -107,15 +126,268 @@ def test_smtp_proxy_handler_forwards_message_over_ssl(monkeypatch):
|
|||||||
Settings.BACKEND_SMTP_USER = "smtp-user"
|
Settings.BACKEND_SMTP_USER = "smtp-user"
|
||||||
Settings.BACKEND_SMTP_PASS = "smtp-pass"
|
Settings.BACKEND_SMTP_PASS = "smtp-pass"
|
||||||
|
|
||||||
|
# Include an 8-bit byte that would be mangled by a utf-8 decode.
|
||||||
|
raw = b"Subject: Test\r\n\r\nBody \x80\r\n"
|
||||||
handler = SMTPProxyHandler()
|
handler = SMTPProxyHandler()
|
||||||
handler.send_message("from@example.com", ["to@example.com"], b"Subject: Test\r\n\r\nBody\r\n")
|
handler.send_message("from@example.com", ["to@example.com"], raw)
|
||||||
|
|
||||||
assert captured["instance"].host == Settings.BACKEND_SMTP_HOST
|
assert captured["instance"].host == Settings.BACKEND_SMTP_HOST
|
||||||
assert captured["instance"].logged_in is True
|
assert captured["instance"].logged_in is True
|
||||||
assert captured["instance"].sent[0] == "from@example.com"
|
assert captured["instance"].sent[0] == "from@example.com"
|
||||||
assert captured["instance"].sent[1] == ["to@example.com"]
|
assert captured["instance"].sent[1] == ["to@example.com"]
|
||||||
assert "Subject: Test" in captured["instance"].sent[2]
|
# Raw bytes must be forwarded unchanged, not decoded.
|
||||||
|
assert captured["instance"].sent[2] == raw
|
||||||
|
|
||||||
Settings.BACKEND_SMTP_USE_SSL = previous_ssl
|
Settings.BACKEND_SMTP_USE_SSL = previous_ssl
|
||||||
Settings.BACKEND_SMTP_USER = previous_user
|
Settings.BACKEND_SMTP_USER = previous_user
|
||||||
Settings.BACKEND_SMTP_PASS = previous_pass
|
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()
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.buffer.extend(data)
|
||||||
|
|
||||||
|
async def drain(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def wait_closed(self):
|
||||||
|
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."""
|
||||||
|
|
||||||
|
def __init__(self, uids):
|
||||||
|
self.uids = list(uids)
|
||||||
|
self.marked = []
|
||||||
|
self.expunged = False
|
||||||
|
self.logged_out = False
|
||||||
|
|
||||||
|
def list_uids(self):
|
||||||
|
return list(self.uids)
|
||||||
|
|
||||||
|
def fetch_all_sizes(self):
|
||||||
|
return {uid: 100 for uid in self.uids}
|
||||||
|
|
||||||
|
def fetch_message(self, uid):
|
||||||
|
return b"Subject: Hi\r\nHeader: 1\r\n\r\n.dotted line\r\nsecond\r\nthird\r\n"
|
||||||
|
|
||||||
|
def mark_deleted(self, uid):
|
||||||
|
self.marked.append(uid)
|
||||||
|
|
||||||
|
def expunge(self):
|
||||||
|
self.expunged = True
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
self.logged_out = True
|
||||||
|
|
||||||
|
|
||||||
|
def make_session(uids):
|
||||||
|
session = POP3Session(None, FakeWriter())
|
||||||
|
session._imap = RecordingIMAP(uids)
|
||||||
|
session.message_ids = session._imap.list_uids()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_pop3_body_dot_stuffs_and_terminates():
|
||||||
|
payload = format_pop3_body(b".secret\r\nplain\r\n")
|
||||||
|
assert payload == b"..secret\r\nplain\r\n.\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_pop3_body_top_keeps_headers_and_limits_body():
|
||||||
|
message = b"Subject: Hi\r\nHeader: 1\r\n\r\nline1\r\nline2\r\nline3\r\n"
|
||||||
|
payload = format_pop3_body(message, max_body_lines=1)
|
||||||
|
assert payload == b"Subject: Hi\r\nHeader: 1\r\n\r\nline1\r\n.\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retr_preserves_leading_dot_line():
|
||||||
|
session = make_session([b"1", b"2"])
|
||||||
|
asyncio.run(session.handle_retr(["1"]))
|
||||||
|
output = bytes(session.writer.buffer)
|
||||||
|
assert b"+OK" in output
|
||||||
|
# The body line ".dotted line" must survive intact after dot-stuffing/parse.
|
||||||
|
assert b"\r\n..dotted line\r\n" in output
|
||||||
|
assert output.endswith(b".\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_returns_headers_and_limited_body():
|
||||||
|
session = make_session([b"1"])
|
||||||
|
asyncio.run(session.handle_top(["1", "1"]))
|
||||||
|
output = bytes(session.writer.buffer)
|
||||||
|
assert output.startswith(b"+OK top of message follows\r\n")
|
||||||
|
assert b"Subject: Hi\r\n" in output
|
||||||
|
assert b"\r\n..dotted line\r\n" in output
|
||||||
|
assert b"second" not in output
|
||||||
|
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"]))
|
||||||
|
# Multidrop status commands must not clear the deletion mark.
|
||||||
|
asyncio.run(session.handle_stat())
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user