2026-08-10 03:10:50 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""Un vrai serveur IMAP et un vrai serveur SMTP, jetables, pour les tests.
|
|
|
|
|
|
|
|
|
|
Pourquoi : tous les autres tests courriel passent par un double
|
|
|
|
|
(`FakeImapTransport`, `MagicMock`). Un double ne produit que ce qu'on avait
|
|
|
|
|
imaginé en l'écrivant — c'est précisément par là que des bugs de protocole
|
|
|
|
|
sont passés jusqu'à l'utilisateur. Ce module ouvre de VRAIES sockets sur
|
|
|
|
|
127.0.0.1 pour que le client soit exercé sur du vrai TCP.
|
|
|
|
|
|
|
|
|
|
L'intérêt n'est pas la conformité : un serveur poli ne prouve pas grand-chose.
|
|
|
|
|
L'intérêt est de pouvoir SE CONDUIRE MAL à la demande — servir un en-tête en
|
|
|
|
|
octets 8 bits, un charset `unknown-8bit`, une connexion coupée en plein FETCH.
|
|
|
|
|
Ajouter une méchanceté doit rester une petite addition (une sous-classe de
|
|
|
|
|
`Fault`, ou de simples octets déclarés par le test), jamais un nouveau serveur.
|
|
|
|
|
|
|
|
|
|
## Le réacteur Twisted ne se redémarre pas
|
|
|
|
|
|
|
|
|
|
`reactor.run()` ne peut être appelé qu'UNE fois par processus ; après
|
|
|
|
|
`reactor.stop()` il refuse de repartir. `unittest` enchaîne les tests dans un
|
|
|
|
|
seul processus : « un réacteur par test » échouerait dès le deuxième test, et
|
|
|
|
|
la panne ressemble à un blocage, pas à une erreur claire.
|
|
|
|
|
|
|
|
|
|
D'où le choix ici : UN seul réacteur, démarré à la demande dans un fil de
|
|
|
|
|
fond, et JAMAIS arrêté avant la fin du processus. Un test n'ouvre et ne ferme
|
|
|
|
|
qu'un port d'écoute (`reactor.listenTCP` / `port.stopListening`). L'état
|
|
|
|
|
propre par test ne vient donc pas du réacteur — il vient des objets : chaque
|
|
|
|
|
test construit ses propres boîtes et ses propres messages, et rien n'est
|
|
|
|
|
partagé entre deux tests. Les tests passent donc dans n'importe quel ordre et
|
|
|
|
|
un par un.
|
|
|
|
|
|
|
|
|
|
`aiosmtpd.Controller` porte sa propre boucle asyncio dans un fil et n'a pas ce
|
|
|
|
|
problème ; il ne sait en revanche pas se lier au port 0 tel quel, voir
|
|
|
|
|
`SmtpSandbox`.
|
|
|
|
|
|
|
|
|
|
## Sécurité
|
|
|
|
|
|
|
|
|
|
Rien ici ne sort de la machine : on se lie à 127.0.0.1 sur le port 0 (l'OS
|
|
|
|
|
choisit), jamais sur un port fixe qui entrerait en collision avec ce qui
|
|
|
|
|
écoute déjà. Aucun trousseau, aucun `~/.erplibre`, aucun identifiant réel.
|
|
|
|
|
"""
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
|
2026-08-10 03:10:50 -04:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import atexit
|
|
|
|
|
import email
|
|
|
|
|
import logging
|
|
|
|
|
import re
|
|
|
|
|
import socket
|
|
|
|
|
import threading
|
|
|
|
|
import unittest
|
|
|
|
|
import warnings
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
from twisted.cred import checkers, portal
|
|
|
|
|
from twisted.internet import protocol
|
|
|
|
|
from twisted.internet.threads import blockingCallFromThread
|
|
|
|
|
from twisted.mail import imap4
|
|
|
|
|
from zope.interface import implementer
|
|
|
|
|
|
|
|
|
|
from script.todo.mail.accounts import Account, ServerConf
|
|
|
|
|
|
|
|
|
|
REACTOR_START_TIMEOUT = 10
|
|
|
|
|
SERVER_STOP_TIMEOUT = 10
|
|
|
|
|
|
|
|
|
|
USER = "moi"
|
|
|
|
|
PASSWORD = "secret"
|
|
|
|
|
|
|
|
|
|
# Tous les bacs à sable actuellement à l'écoute. Sert de preuve de non-fuite :
|
|
|
|
|
# à la fin d'un test l'ensemble doit être revenu à ce qu'il était (voir
|
|
|
|
|
# `TestSandboxLifecycle` dans `test_mail_live_server.py`).
|
|
|
|
|
LIVE_SERVERS: set = set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Le réacteur, un seul, dans un fil de fond
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_reactor_lock = threading.Lock()
|
|
|
|
|
_reactor_thread: threading.Thread | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reactor_in_thread():
|
|
|
|
|
"""Le réacteur global, en marche dans un fil de fond.
|
|
|
|
|
|
|
|
|
|
Idempotent : le premier appel le démarre, les suivants le retrouvent. On
|
|
|
|
|
ne l'arrête qu'à la sortie du processus (`atexit`), parce qu'un réacteur
|
|
|
|
|
arrêté ne repart jamais.
|
|
|
|
|
"""
|
|
|
|
|
global _reactor_thread
|
|
|
|
|
from twisted.internet import reactor
|
|
|
|
|
|
|
|
|
|
with _reactor_lock:
|
|
|
|
|
if _reactor_thread is None:
|
|
|
|
|
running = threading.Event()
|
|
|
|
|
reactor.callWhenRunning(running.set)
|
|
|
|
|
_reactor_thread = threading.Thread(
|
|
|
|
|
target=reactor.run,
|
|
|
|
|
kwargs={"installSignalHandlers": False},
|
|
|
|
|
name="mail-sandbox-reactor",
|
|
|
|
|
daemon=True,
|
|
|
|
|
)
|
|
|
|
|
_reactor_thread.start()
|
|
|
|
|
if not running.wait(REACTOR_START_TIMEOUT):
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"le réacteur Twisted n'a pas démarré en"
|
|
|
|
|
f" {REACTOR_START_TIMEOUT}s"
|
|
|
|
|
)
|
|
|
|
|
atexit.register(_stop_reactor)
|
|
|
|
|
return reactor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _stop_reactor() -> None:
|
|
|
|
|
"""Arrêt de fin de processus. Le fil est `daemon` : même si le réacteur
|
|
|
|
|
reste coincé, il n'empêchera pas Python de sortir."""
|
|
|
|
|
global _reactor_thread
|
|
|
|
|
from twisted.internet import reactor
|
|
|
|
|
|
|
|
|
|
thread, _reactor_thread = _reactor_thread, None
|
|
|
|
|
if thread is None:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
reactor.callFromThread(reactor.stop)
|
|
|
|
|
except Exception:
|
|
|
|
|
# Le réacteur peut déjà être mort : la sortie du processus ne doit
|
|
|
|
|
# jamais échouer là-dessus.
|
|
|
|
|
return
|
|
|
|
|
thread.join(SERVER_STOP_TIMEOUT)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Les méchancetés
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class Fault:
|
|
|
|
|
"""Une panne serveur déclenchée par une commande cliente.
|
|
|
|
|
|
|
|
|
|
Ajouter une méchanceté = une sous-classe de trois lignes. `command` dit
|
|
|
|
|
sur quelle commande elle se déclenche (`b"FETCH"`, `b"SELECT"`...),
|
|
|
|
|
`after` combien d'occurrences on laisse passer avant de frapper — c'est
|
|
|
|
|
ce qui permet de couper la connexion au milieu d'une passe plutôt qu'à
|
|
|
|
|
son premier mot — et `strike()` fait le mal.
|
|
|
|
|
|
|
|
|
|
Le déclenchement est compté, pas chronométré : aucun test ne dépend
|
|
|
|
|
d'une durée, donc aucun ne devient instable sur une machine chargée.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
command: bytes
|
|
|
|
|
after: int = 0
|
|
|
|
|
fired: int = field(default=0, init=False)
|
|
|
|
|
|
|
|
|
|
def matches(self, command: bytes) -> bool:
|
|
|
|
|
if command.upper() != self.command.upper():
|
|
|
|
|
return False
|
|
|
|
|
self.fired += 1
|
|
|
|
|
return self.fired > self.after
|
|
|
|
|
|
|
|
|
|
def strike(self, server, tag) -> None:
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class DropConnection(Fault):
|
|
|
|
|
"""Le serveur raccroche sans un mot, la commande restée sans réponse.
|
|
|
|
|
|
|
|
|
|
C'est la panne réseau ordinaire — coupure Wi-Fi, pare-feu, serveur qui
|
|
|
|
|
redémarre — et celle qu'aucun double n'a jamais produite, puisqu'un
|
|
|
|
|
double répond toujours.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def strike(self, server, tag) -> None:
|
|
|
|
|
server.transport.abortConnection()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class RefuseCommand(Fault):
|
|
|
|
|
"""Le serveur répond NO. Un dossier qu'on n'a pas le droit de lire, un
|
|
|
|
|
quota dépassé : le client doit continuer sur les autres dossiers."""
|
|
|
|
|
|
|
|
|
|
text: bytes = b"Sandbox refuses this command"
|
|
|
|
|
|
|
|
|
|
def strike(self, server, tag) -> None:
|
|
|
|
|
server.sendNegativeResponse(tag, self.text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Le contenu servi : des octets, tels que le test les déclare
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _as_text(value) -> str:
|
|
|
|
|
return (
|
|
|
|
|
value.decode("ascii", "replace") if isinstance(value, bytes) else value
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _as_bytes(value) -> bytes:
|
|
|
|
|
return (
|
|
|
|
|
value.encode("ascii", "replace") if isinstance(value, str) else value
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _split_header_lines(raw: bytes) -> list[bytes]:
|
|
|
|
|
"""Les lignes d'en-tête de `raw`, repliements compris, terminées en CRLF.
|
|
|
|
|
|
|
|
|
|
On accepte le LF seul en entrée : c'est ce que rend `as_bytes()`, donc ce
|
|
|
|
|
que le client dépose vraiment par APPEND. Découper sur le seul CRLF
|
|
|
|
|
rendrait alors le message ENTIER comme un unique en-tête, sans rien lever.
|
|
|
|
|
Le terminateur rendu, lui, est toujours CRLF — c'est le format du fil.
|
|
|
|
|
"""
|
|
|
|
|
head = re.split(rb"\r?\n\r?\n", raw, maxsplit=1)[0]
|
|
|
|
|
lines: list[bytes] = []
|
|
|
|
|
for line in re.split(rb"\r?\n", head):
|
|
|
|
|
if not line:
|
|
|
|
|
continue
|
|
|
|
|
if line[:1] in (b" ", b"\t") and lines:
|
|
|
|
|
lines[-1] += b"\r\n" + line
|
|
|
|
|
else:
|
|
|
|
|
lines.append(line)
|
|
|
|
|
return [line + b"\r\n" for line in lines]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@implementer(imap4.IMessage, imap4.IMessageFile)
|
|
|
|
|
class SandboxMessage:
|
|
|
|
|
"""Un message servi VERBATIM, tel que le test l'a écrit.
|
|
|
|
|
|
|
|
|
|
`IMessageFile` (une seule méthode, `open()`) est ce qui rend possible de
|
|
|
|
|
servir des octets hostiles : sur un FETCH du message entier, Twisted
|
|
|
|
|
recopie ce flux tel quel au lieu de repasser par son chemin MIME, qui
|
|
|
|
|
finit en `networkString()` → `.encode("ascii")` et refuserait tout octet
|
|
|
|
|
8 bits.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, uid: int, raw: bytes, flags=(), internal_date=None):
|
|
|
|
|
self.uid = uid
|
|
|
|
|
self.raw = raw
|
|
|
|
|
self.flags = list(flags)
|
|
|
|
|
self.internal_date = internal_date or b"06-Aug-2026 10:00:00 +0000"
|
|
|
|
|
self.parsed = email.message_from_bytes(raw)
|
|
|
|
|
|
|
|
|
|
# -- IMessagePart / IMessage ----------------------------------------
|
|
|
|
|
|
|
|
|
|
def getUID(self) -> int:
|
|
|
|
|
return self.uid
|
|
|
|
|
|
|
|
|
|
def getFlags(self) -> list:
|
|
|
|
|
return list(self.flags)
|
|
|
|
|
|
|
|
|
|
def getInternalDate(self) -> bytes:
|
|
|
|
|
return self.internal_date
|
|
|
|
|
|
|
|
|
|
def getHeaders(self, negate, *names) -> dict:
|
|
|
|
|
"""Les en-têtes en `str` — ce que Twisted attend (il fait
|
|
|
|
|
`v.splitlines()`, donc surtout pas un `email.header.Header`).
|
|
|
|
|
|
|
|
|
|
Les NOMS demandés, eux, arrivent en OCTETS depuis le serveur
|
|
|
|
|
(`IMAP4Server.spew_body` passe `part.header.fields`), alors que les
|
|
|
|
|
recherches internes (`search_SUBJECT`...) les passent en `str`. Une
|
|
|
|
|
comparaison sur un seul des deux types rend un dictionnaire vide —
|
|
|
|
|
sans erreur, et donc sans rien pour la faire remarquer : le client ne
|
|
|
|
|
voit qu'un message sans sujet ni date.
|
|
|
|
|
"""
|
|
|
|
|
wanted = {_as_text(n).upper() for n in names}
|
|
|
|
|
return {
|
|
|
|
|
key: str(value)
|
|
|
|
|
for key, value in self.parsed.items()
|
|
|
|
|
if (
|
|
|
|
|
(key.upper() not in wanted)
|
|
|
|
|
if negate
|
|
|
|
|
else (key.upper() in wanted)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def raw_header_block(self, negate, fields) -> bytes:
|
|
|
|
|
"""Les mêmes en-têtes, mais en OCTETS bruts.
|
|
|
|
|
|
|
|
|
|
Twisted ne sait pas les servir : `_formatHeaders` finit par
|
|
|
|
|
`networkString()`, donc `.encode("ascii")`, et lève sur le moindre
|
|
|
|
|
octet 8 bits. Or c'est exactement ce qu'un vrai serveur nous a
|
|
|
|
|
envoyé le jour du bug. `SandboxIMAP4Server.spew_body` bascule ici
|
|
|
|
|
quand le bloc n'est pas ASCII (voir sa docstring).
|
|
|
|
|
"""
|
|
|
|
|
wanted = {_as_bytes(f).upper() for f in fields}
|
|
|
|
|
out = []
|
|
|
|
|
for line in _split_header_lines(self.raw):
|
|
|
|
|
name = line.split(b":", 1)[0].strip().upper()
|
|
|
|
|
if (name not in wanted) if negate else (name in wanted):
|
|
|
|
|
out.append(line)
|
|
|
|
|
return b"".join(out) + b"\r\n"
|
|
|
|
|
|
|
|
|
|
def open(self):
|
|
|
|
|
return BytesIO(self.raw)
|
|
|
|
|
|
|
|
|
|
def getBodyFile(self):
|
|
|
|
|
parts = re.split(rb"\r?\n\r?\n", self.raw, maxsplit=1)
|
|
|
|
|
return BytesIO(parts[1] if len(parts) > 1 else b"")
|
|
|
|
|
|
|
|
|
|
def getSize(self) -> int:
|
|
|
|
|
return len(self.raw)
|
|
|
|
|
|
|
|
|
|
def isMultipart(self) -> bool:
|
|
|
|
|
return self.parsed.is_multipart()
|
|
|
|
|
|
|
|
|
|
def getSubPart(self, part):
|
|
|
|
|
raise TypeError("le bac à sable ne sert pas de sous-partie")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@implementer(imap4.IMailbox, imap4.IMailboxInfo)
|
|
|
|
|
class SandboxMailbox:
|
|
|
|
|
def __init__(self, name: str, uidvalidity: int = 42):
|
|
|
|
|
self.name = name
|
|
|
|
|
self.uidvalidity = uidvalidity
|
|
|
|
|
self.messages: list[SandboxMessage] = []
|
|
|
|
|
self.listeners: list = []
|
|
|
|
|
self.appended: list[tuple[bytes, tuple]] = []
|
|
|
|
|
|
|
|
|
|
# -- écriture par le test -------------------------------------------
|
|
|
|
|
|
|
|
|
|
def deliver(self, raw: bytes, flags=(), uid: int | None = None):
|
|
|
|
|
message = SandboxMessage(
|
|
|
|
|
uid if uid is not None else self.getUIDNext(), raw, flags
|
|
|
|
|
)
|
|
|
|
|
self.messages.append(message)
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
def _max_uid(self) -> int:
|
|
|
|
|
return max((m.uid for m in self.messages), default=0)
|
|
|
|
|
|
|
|
|
|
# -- IMailbox --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def getFlags(self) -> list:
|
|
|
|
|
return ["\\Seen", "\\Answered", "\\Flagged", "\\Deleted", "\\Draft"]
|
|
|
|
|
|
|
|
|
|
def getUIDValidity(self) -> int:
|
|
|
|
|
return self.uidvalidity
|
|
|
|
|
|
|
|
|
|
def getUIDNext(self) -> int:
|
|
|
|
|
return self._max_uid() + 1
|
|
|
|
|
|
|
|
|
|
def getUID(self, message: int) -> int:
|
|
|
|
|
return self.messages[message - 1].uid
|
|
|
|
|
|
|
|
|
|
def getMessageCount(self) -> int:
|
|
|
|
|
return len(self.messages)
|
|
|
|
|
|
|
|
|
|
def getRecentCount(self) -> int:
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
def getUnseenCount(self) -> int:
|
|
|
|
|
return sum(1 for m in self.messages if "\\Seen" not in m.flags)
|
|
|
|
|
|
|
|
|
|
def isWriteable(self) -> bool:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def getHierarchicalDelimiter(self) -> str:
|
|
|
|
|
return "."
|
|
|
|
|
|
|
|
|
|
def requestStatus(self, names):
|
|
|
|
|
return imap4.statusRequestHelper(self, names)
|
|
|
|
|
|
|
|
|
|
def addListener(self, listener) -> None:
|
|
|
|
|
self.listeners.append(listener)
|
|
|
|
|
|
|
|
|
|
def removeListener(self, listener) -> None:
|
|
|
|
|
if listener in self.listeners:
|
|
|
|
|
self.listeners.remove(listener)
|
|
|
|
|
|
|
|
|
|
def addMessage(self, body, flags=(), date=None):
|
|
|
|
|
"""APPEND. Doit rendre un `Deferred` et non un entier : Twisted fait
|
|
|
|
|
`d.addCallback(...)` sur le résultat sans le passer par
|
|
|
|
|
`maybeDeferred`, et un entier y devient un « Server error encountered
|
|
|
|
|
while opening mailbox » — un message qui désigne le mauvais coupable.
|
|
|
|
|
"""
|
|
|
|
|
from twisted.internet import defer
|
|
|
|
|
|
|
|
|
|
raw = body.read() if hasattr(body, "read") else body
|
|
|
|
|
self.appended.append((raw, tuple(flags)))
|
|
|
|
|
self.deliver(raw, flags)
|
|
|
|
|
return defer.succeed(len(self.messages))
|
|
|
|
|
|
|
|
|
|
def fetch(self, messages, uid):
|
|
|
|
|
"""`messages` est un `MessageSet` : il faut lui donner sa borne haute
|
|
|
|
|
avant de l'interroger, sinon `*` ne veut rien dire."""
|
|
|
|
|
if uid:
|
|
|
|
|
messages.last = self._max_uid()
|
|
|
|
|
return [(m.uid, m) for m in self.messages if m.uid in messages]
|
|
|
|
|
messages.last = len(self.messages)
|
|
|
|
|
return [
|
|
|
|
|
(index + 1, m)
|
|
|
|
|
for index, m in enumerate(self.messages)
|
|
|
|
|
if index + 1 in messages
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def store(self, messages, flags, mode, uid):
|
|
|
|
|
out = {}
|
|
|
|
|
for number, message in self.fetch(messages, uid):
|
|
|
|
|
current = set(message.flags)
|
|
|
|
|
if mode < 0:
|
|
|
|
|
current -= set(flags)
|
|
|
|
|
elif mode > 0:
|
|
|
|
|
current |= set(flags)
|
|
|
|
|
else:
|
|
|
|
|
current = set(flags)
|
|
|
|
|
message.flags = sorted(current)
|
|
|
|
|
out[number] = message.flags
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
def expunge(self) -> list:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
def destroy(self) -> None:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@implementer(imap4.IAccount)
|
|
|
|
|
class SandboxIMAPAccount:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.boxes: dict[str, SandboxMailbox] = {}
|
|
|
|
|
|
|
|
|
|
def add(self, name: str, uidvalidity: int = 42) -> SandboxMailbox:
|
|
|
|
|
box = SandboxMailbox(name, uidvalidity)
|
|
|
|
|
self.boxes[name] = box
|
|
|
|
|
return box
|
|
|
|
|
|
|
|
|
|
def _key(self, path: str) -> str:
|
|
|
|
|
# INBOX est insensible à la casse (RFC 3501), le reste ne l'est pas.
|
|
|
|
|
return "INBOX" if path.upper() == "INBOX" else path
|
|
|
|
|
|
|
|
|
|
def listMailboxes(self, ref, wildcard):
|
|
|
|
|
return list(self.boxes.items())
|
|
|
|
|
|
|
|
|
|
def select(self, path, rw=True):
|
|
|
|
|
return self.boxes.get(self._key(path))
|
|
|
|
|
|
|
|
|
|
def create(self, path):
|
|
|
|
|
self.add(self._key(path))
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def delete(self, path):
|
|
|
|
|
self.boxes.pop(self._key(path), None)
|
|
|
|
|
|
|
|
|
|
def rename(self, old, new):
|
|
|
|
|
self.boxes[self._key(new)] = self.boxes.pop(self._key(old))
|
|
|
|
|
|
|
|
|
|
def isSubscribed(self, name):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def subscribe(self, name):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def unsubscribe(self, name):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@implementer(portal.IRealm)
|
|
|
|
|
class _SandboxRealm:
|
|
|
|
|
def __init__(self, account: SandboxIMAPAccount):
|
|
|
|
|
self.account = account
|
|
|
|
|
|
|
|
|
|
def requestAvatar(self, avatarId, mind, *interfaces):
|
|
|
|
|
return imap4.IAccount, self.account, lambda: None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Le serveur IMAP
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SandboxIMAP4Server(imap4.IMAP4Server):
|
|
|
|
|
def __init__(self, sandbox: "ImapSandbox"):
|
|
|
|
|
# `IMAP4Server.__init__` prend (chal, contextFactory, scheduler) et
|
|
|
|
|
# NON un portal : celui-ci s'affecte après coup.
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.sandbox = sandbox
|
|
|
|
|
|
|
|
|
|
def connectionMade(self):
|
|
|
|
|
self.sandbox.connections.add(self)
|
|
|
|
|
super().connectionMade()
|
|
|
|
|
|
|
|
|
|
def connectionLost(self, reason):
|
|
|
|
|
self.sandbox.connections.discard(self)
|
|
|
|
|
super().connectionLost(reason)
|
|
|
|
|
|
|
|
|
|
def dispatchCommand(self, tag, cmd, rest, uid=None):
|
|
|
|
|
"""Le seul point où les méchancetés s'insèrent.
|
|
|
|
|
|
|
|
|
|
`UID FETCH ...` passe ici deux fois — une pour `UID`, une pour le
|
|
|
|
|
`FETCH` interne — ce qui permet à un `Fault` de viser précisément
|
|
|
|
|
l'une ou l'autre.
|
|
|
|
|
"""
|
|
|
|
|
for fault in self.sandbox.faults:
|
|
|
|
|
if fault.matches(cmd):
|
|
|
|
|
fault.strike(self, tag)
|
|
|
|
|
return None
|
|
|
|
|
return super().dispatchCommand(tag, cmd, rest, uid)
|
|
|
|
|
|
|
|
|
|
def spew_body(self, part, id, msg, _w=None, _f=None):
|
|
|
|
|
"""Sert les en-têtes en octets bruts quand ils ne sont pas ASCII.
|
|
|
|
|
|
|
|
|
|
Par défaut on laisse faire Twisted : le bac à sable est un serveur
|
|
|
|
|
POLI, et les tests doivent traverser son vrai code. Mais son
|
|
|
|
|
`_formatHeaders` se termine par `networkString()` — un `.encode(
|
|
|
|
|
"ascii")` — et lève sur le moindre octet 8 bits, que tout vrai
|
|
|
|
|
serveur transmet pourtant sans broncher. Dans ce seul cas on écrit
|
|
|
|
|
le littéral nous-mêmes, avec les octets déclarés par le test. Le
|
|
|
|
|
cadrage du littéral reste celui de Twisted (`imap4._literal`).
|
|
|
|
|
"""
|
|
|
|
|
block = None
|
|
|
|
|
if part.header is not None:
|
|
|
|
|
raw = getattr(msg, "raw_header_block", None)
|
|
|
|
|
if raw is not None:
|
|
|
|
|
block = raw(part.header.negate, part.header.fields)
|
|
|
|
|
if block is None or _is_ascii(block):
|
|
|
|
|
return super().spew_body(part, id, msg, _w, _f)
|
|
|
|
|
write = _w if _w is not None else self.transport.write
|
|
|
|
|
write(bytes(part) + b" " + imap4._literal(block))
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_ascii(data: bytes) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
data.decode("ascii")
|
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _SandboxFactory(protocol.Factory):
|
|
|
|
|
def __init__(self, sandbox: "ImapSandbox"):
|
|
|
|
|
self.sandbox = sandbox
|
|
|
|
|
|
|
|
|
|
def buildProtocol(self, addr):
|
|
|
|
|
server = SandboxIMAP4Server(self.sandbox)
|
|
|
|
|
server.factory = self
|
|
|
|
|
server.portal = self.sandbox.portal
|
|
|
|
|
return server
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImapSandbox:
|
|
|
|
|
"""Un serveur IMAP jetable, sur un port éphémère de 127.0.0.1.
|
|
|
|
|
|
|
|
|
|
Usage :
|
|
|
|
|
|
|
|
|
|
imap = ImapSandbox()
|
|
|
|
|
imap.folder("INBOX").deliver(RAW_BYTES, flags=["\\\\Seen"])
|
|
|
|
|
imap.fail(DropConnection(b"FETCH", after=1))
|
|
|
|
|
imap.start()
|
|
|
|
|
...
|
|
|
|
|
imap.stop()
|
|
|
|
|
|
|
|
|
|
`MailSandboxCase.imap_server()` fait tout cela et branche l'arrêt sur
|
|
|
|
|
`addCleanup`, qui s'exécute même quand le test échoue.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.account = SandboxIMAPAccount()
|
|
|
|
|
self.faults: list[Fault] = []
|
|
|
|
|
self.connections: set = set()
|
|
|
|
|
self.port = 0
|
|
|
|
|
self._listening = None
|
|
|
|
|
checker = checkers.InMemoryUsernamePasswordDatabaseDontUse()
|
|
|
|
|
checker.addUser(USER.encode(), PASSWORD.encode())
|
|
|
|
|
self.portal = portal.Portal(_SandboxRealm(self.account))
|
|
|
|
|
self.portal.registerChecker(checker)
|
|
|
|
|
|
|
|
|
|
# -- déclaration du contenu -----------------------------------------
|
|
|
|
|
|
|
|
|
|
def folder(self, name: str, uidvalidity: int = 42) -> SandboxMailbox:
|
|
|
|
|
return self.account.boxes.get(name) or self.account.add(
|
|
|
|
|
name, uidvalidity
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def fail(self, fault: Fault) -> Fault:
|
|
|
|
|
self.faults.append(fault)
|
|
|
|
|
return fault
|
|
|
|
|
|
|
|
|
|
# -- cycle de vie -----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def start(self) -> "ImapSandbox":
|
|
|
|
|
reactor = reactor_in_thread()
|
|
|
|
|
self._listening = blockingCallFromThread(
|
|
|
|
|
reactor,
|
|
|
|
|
reactor.listenTCP,
|
|
|
|
|
0,
|
|
|
|
|
_SandboxFactory(self),
|
|
|
|
|
interface="127.0.0.1",
|
|
|
|
|
)
|
|
|
|
|
self.port = self._listening.getHost().port
|
|
|
|
|
LIVE_SERVERS.add(self)
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def stop(self) -> None:
|
|
|
|
|
"""Ferme le port ET coupe les connexions encore ouvertes.
|
|
|
|
|
|
|
|
|
|
`stopListening` seul cesse d'ACCEPTER : une session cliente restée
|
|
|
|
|
ouverte garderait un descripteur et un protocole vivants d'un test à
|
|
|
|
|
l'autre.
|
|
|
|
|
"""
|
|
|
|
|
listening, self._listening = self._listening, None
|
|
|
|
|
LIVE_SERVERS.discard(self)
|
|
|
|
|
if listening is None:
|
|
|
|
|
return
|
|
|
|
|
from twisted.internet import reactor
|
|
|
|
|
|
|
|
|
|
def close():
|
|
|
|
|
for server in list(self.connections):
|
|
|
|
|
server.transport.abortConnection()
|
|
|
|
|
return listening.stopListening()
|
|
|
|
|
|
|
|
|
|
blockingCallFromThread(reactor, close)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Le serveur SMTP
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class SentMessage:
|
|
|
|
|
"""Ce qui est VRAIMENT sorti : l'enveloppe et les octets sur le fil."""
|
|
|
|
|
|
|
|
|
|
mail_from: str
|
|
|
|
|
rcpt_tos: list
|
|
|
|
|
content: bytes
|
|
|
|
|
|
|
|
|
|
def headers(self):
|
|
|
|
|
return email.message_from_bytes(self.content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _CaptureHandler:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.messages: list[SentMessage] = []
|
|
|
|
|
|
|
|
|
|
async def handle_DATA(self, server, session, envelope):
|
|
|
|
|
self.messages.append(
|
|
|
|
|
SentMessage(
|
|
|
|
|
mail_from=envelope.mail_from,
|
|
|
|
|
rcpt_tos=list(envelope.rcpt_tos),
|
|
|
|
|
content=bytes(envelope.content),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return "250 Message accepted for delivery"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SmtpSandbox:
|
|
|
|
|
"""Un serveur SMTP jetable qui capture ce qu'on lui remet.
|
|
|
|
|
|
|
|
|
|
`aiosmtpd.Controller` ne sait pas se lier au port 0 : après `start()` il
|
|
|
|
|
rouvre une connexion de vérification vers `self.port`, qui vaut encore 0.
|
|
|
|
|
On relit le vrai numéro sur la socket avant cette vérification — c'est le
|
|
|
|
|
seul point à corriger.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, *, require_auth: bool = False):
|
|
|
|
|
from aiosmtpd.controller import Controller
|
|
|
|
|
from aiosmtpd.smtp import AuthResult, LoginPassword
|
|
|
|
|
|
|
|
|
|
# Deux bruits d'`aiosmtpd` sans objet ici, et qui masqueraient les
|
|
|
|
|
# vraies pannes dans la sortie des tests : un WARNING à chaque
|
|
|
|
|
# authentification réussie (« Session.login_data is deprecated »), et
|
|
|
|
|
# un avertissement sur AUTH sans TLS — justifié en production, sans
|
|
|
|
|
# objet pour un serveur qu'on vient de démarrer soi-même sur la
|
|
|
|
|
# boucle locale. Le filtre est posé ici et non à l'import : le
|
|
|
|
|
# lanceur `unittest` réinitialise `warnings.filters` avant de courir.
|
|
|
|
|
logging.getLogger("mail.log").setLevel(logging.ERROR)
|
|
|
|
|
warnings.filterwarnings(
|
|
|
|
|
"ignore",
|
|
|
|
|
message="Requiring AUTH while not requiring TLS",
|
|
|
|
|
category=UserWarning,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def authenticate(server, session, envelope, mechanism, auth_data):
|
|
|
|
|
ok = isinstance(auth_data, LoginPassword) and (
|
|
|
|
|
auth_data.login == USER.encode()
|
|
|
|
|
and auth_data.password == PASSWORD.encode()
|
|
|
|
|
)
|
|
|
|
|
if ok:
|
|
|
|
|
return AuthResult(success=True, auth_data=auth_data)
|
|
|
|
|
# `handled` vaut True PAR DÉFAUT, et veut dire « j'ai déjà répondu
|
|
|
|
|
# au client moi-même ». Un simple `AuthResult(success=False)`
|
|
|
|
|
# laisse donc `aiosmtpd` muet : le client attend une réponse qui
|
|
|
|
|
# ne vient jamais et le test se bloque jusqu'au délai de la
|
|
|
|
|
# socket, sans rien dire de la cause.
|
|
|
|
|
return AuthResult(success=False, handled=False)
|
|
|
|
|
|
|
|
|
|
class _Port0Controller(Controller):
|
|
|
|
|
def _trigger_server(self):
|
|
|
|
|
if self.port == 0 and self.server is not None:
|
|
|
|
|
self.port = self.server.sockets[0].getsockname()[1]
|
|
|
|
|
super()._trigger_server()
|
|
|
|
|
|
|
|
|
|
self.handler = _CaptureHandler()
|
|
|
|
|
self.controller = _Port0Controller(
|
|
|
|
|
self.handler,
|
|
|
|
|
hostname="127.0.0.1",
|
|
|
|
|
port=0,
|
|
|
|
|
authenticator=authenticate,
|
|
|
|
|
auth_required=require_auth,
|
|
|
|
|
auth_require_tls=False,
|
|
|
|
|
)
|
|
|
|
|
self.port = 0
|
|
|
|
|
self._running = False
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def messages(self) -> list[SentMessage]:
|
|
|
|
|
return self.handler.messages
|
|
|
|
|
|
|
|
|
|
def start(self) -> "SmtpSandbox":
|
|
|
|
|
# Marqué vivant AVANT de démarrer : `Controller.start()` lance déjà
|
|
|
|
|
# son fil avant de pouvoir échouer, et le nettoyage doit passer
|
|
|
|
|
# derrière lui même dans ce cas-là.
|
|
|
|
|
self._running = True
|
|
|
|
|
LIVE_SERVERS.add(self)
|
|
|
|
|
self.controller.start()
|
|
|
|
|
self.port = self.controller.port
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def stop(self) -> None:
|
|
|
|
|
"""Idempotent : un test peut vouloir tuer son serveur en plein
|
|
|
|
|
milieu, et le nettoyage repassera derrière lui de toute façon."""
|
|
|
|
|
if not self._running:
|
|
|
|
|
return
|
|
|
|
|
self._running = False
|
|
|
|
|
LIVE_SERVERS.discard(self)
|
|
|
|
|
self.controller.stop(no_assert=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Le compte, et le socle de test
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sandbox_account(
|
|
|
|
|
imap_port: int = 0,
|
|
|
|
|
smtp_port: int = 0,
|
|
|
|
|
*,
|
|
|
|
|
name: str = "bac-a-sable",
|
|
|
|
|
address: str = "moi@example.ca",
|
|
|
|
|
display_name: str = "",
|
|
|
|
|
sent_folder: str = "INBOX.Sent",
|
|
|
|
|
) -> Account:
|
|
|
|
|
"""Un `Account` réel pointé sur les serveurs jetables.
|
|
|
|
|
|
|
|
|
|
`security="none"` : on parle en clair sur la boucle locale, à un serveur
|
|
|
|
|
qu'on vient de démarrer soi-même. Rien de tout cela ne quitte la machine.
|
|
|
|
|
"""
|
|
|
|
|
return Account(
|
|
|
|
|
name=name,
|
|
|
|
|
email=address,
|
|
|
|
|
display_name=display_name,
|
|
|
|
|
preset="generic",
|
|
|
|
|
imap=ServerConf(
|
|
|
|
|
host="127.0.0.1", port=imap_port, security="none", user=USER
|
|
|
|
|
),
|
|
|
|
|
smtp=ServerConf(
|
|
|
|
|
host="127.0.0.1", port=smtp_port, security="none", user=USER
|
|
|
|
|
),
|
|
|
|
|
secret_ref="kdbx:ERPLibre/Mail/bac-a-sable",
|
|
|
|
|
cache_mode="clear",
|
|
|
|
|
sent_folder=sent_folder,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def close_imap_client(transport) -> None:
|
|
|
|
|
"""Ferme la socket cliente, quoi qu'il soit arrivé pendant le test.
|
|
|
|
|
|
|
|
|
|
`ImaplibTransport.logout()` est best-effort : sur une connexion déjà
|
|
|
|
|
morte — exactement ce que `DropConnection` produit — `imaplib.logout()`
|
|
|
|
|
lève avant d'atteindre son propre `shutdown()`, et le descripteur reste
|
|
|
|
|
ouvert jusqu'au ramasse-miettes. Acceptable dans le TUI, pas dans une
|
|
|
|
|
suite de tests où il s'accumulerait.
|
|
|
|
|
"""
|
|
|
|
|
transport.logout()
|
|
|
|
|
try:
|
|
|
|
|
transport.client.shutdown()
|
|
|
|
|
except Exception:
|
|
|
|
|
# Déjà fermée : c'est le cas normal quand `logout()` a réussi.
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def port_is_closed(port: int, timeout: float = 0.5) -> bool:
|
|
|
|
|
"""Vrai si plus rien n'écoute sur ce port de la boucle locale."""
|
|
|
|
|
with socket.socket() as probe:
|
|
|
|
|
probe.settimeout(timeout)
|
|
|
|
|
return probe.connect_ex(("127.0.0.1", port)) != 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MailSandboxCase(unittest.TestCase):
|
|
|
|
|
"""Le socle : tout serveur démarré ici meurt avec le test.
|
|
|
|
|
|
|
|
|
|
L'arrêt passe par `addCleanup`, enregistré AVANT le démarrage :
|
|
|
|
|
`unittest` l'exécute quel que soit le sort du test — succès, échec ou
|
|
|
|
|
erreur — et même si `start()` lève à mi-chemin. Une socket d'écoute
|
|
|
|
|
oubliée ou un fil coincé empoisonneraient toute la suite.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def imap_server(self) -> ImapSandbox:
|
|
|
|
|
sandbox = ImapSandbox()
|
|
|
|
|
self.addCleanup(sandbox.stop)
|
|
|
|
|
return sandbox.start()
|
|
|
|
|
|
|
|
|
|
def smtp_server(self, **kwargs) -> SmtpSandbox:
|
|
|
|
|
sandbox = SmtpSandbox(**kwargs)
|
|
|
|
|
self.addCleanup(sandbox.stop)
|
|
|
|
|
return sandbox.start()
|
|
|
|
|
|
|
|
|
|
def temp_store(self, account):
|
|
|
|
|
"""Un cache SQLite dans un dossier temporaire, jamais le vrai."""
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from script.todo.mail.store import Store
|
|
|
|
|
|
|
|
|
|
tmp = tempfile.TemporaryDirectory()
|
|
|
|
|
self.addCleanup(tmp.cleanup)
|
|
|
|
|
store = Store(account, mode="clear", base=Path(tmp.name))
|
|
|
|
|
self.addCleanup(store.close)
|
|
|
|
|
store.open()
|
|
|
|
|
return store
|
|
|
|
|
|
|
|
|
|
def imap_transport(self, sandbox: ImapSandbox, account=None):
|
|
|
|
|
"""Le VRAI client (`imap_transport.connect`), branché sur le bac à
|
|
|
|
|
sable — connexion et LOGIN compris."""
|
|
|
|
|
from script.todo.mail import imap_transport
|
|
|
|
|
|
|
|
|
|
account = account or sandbox_account(imap_port=sandbox.port)
|
|
|
|
|
transport = imap_transport.connect(account, PASSWORD)
|
|
|
|
|
self.addCleanup(close_imap_client, transport)
|
|
|
|
|
return transport
|