#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Les tests courriel qui parlent à un VRAI serveur. Tous les autres tests du paquet `mail` passent par un double. Ceux-ci ouvrent une socket sur 127.0.0.1, vers un serveur IMAP (Twisted) et un serveur SMTP (aiosmtpd) démarrés puis tués par le test lui-même — voir `mail_sandbox.py`. Ce qu'on cherche n'est PAS la conformité : c'est de reproduire ce qu'un double n'aurait jamais produit, parce que son auteur ne l'avait pas imaginé. Chaque classe ci-dessous nomme le risque réel qu'elle couvre. Ils NE tournent PAS dans la boucle rapide : sans `twisted` ni `aiosmtpd`, tout le fichier se saute proprement. Pour les lancer volontairement : .venv.erplibre/bin/python -m unittest discover -s test \\ -p test_mail_live_server.py -v """ import email.utils import threading import unittest import unittest.mock try: import aiosmtpd # noqa: F401 import twisted # noqa: F401 SANDBOX_MISSING = "" except ImportError as exc: # pragma: no cover - dépend de l'installation SANDBOX_MISSING = str(exc) if SANDBOX_MISSING: # pragma: no cover - dépend de l'installation MailSandboxCase = unittest.TestCase else: from mail_sandbox import ( LIVE_SERVERS, PASSWORD, DropConnection, MailSandboxCase, RefuseCommand, port_is_closed, sandbox_account, ) requires_servers = unittest.skipIf( bool(SANDBOX_MISSING), f"serveurs de test absents ({SANDBOX_MISSING})" " : pip install -r requirement/erplibre_require-ments.txt", ) # Le message qui a cassé la production. Deux méchancetés en une : un mot # encodé RFC 2047 qui annonce `unknown-8bit`, étiquette qu'aucun codec Python # ne connaît, et un `From` qui porte des octets 8 bits BRUTS, sans encodage # d'aucune sorte. Les deux existent en vrai ; aucun double ne les produisait. HOSTILE = ( b"From: Ren\xe9 Lavall\xe9e \r\n" b"To: moi@example.ca\r\n" b"Subject: =?unknown-8bit?Q?sujet_h=E9rit=E9?=\r\n" b"Date: Wed, 06 Aug 2026 10:00:00 +0000\r\n" b"Message-ID: \r\n" b'Content-Type: text/plain; charset="unknown-8bit"\r\n' b"\r\n" b"Bonjour, corps accentu\xe9.\r\n" ) def polite(uid: int, subject: str = "Devis") -> bytes: """Un message ordinaire, servi par le chemin normal de Twisted.""" return ( f"From: Alice \r\n" f"To: moi@example.ca\r\n" f"Subject: {subject}\r\n" f"Date: Wed, 06 Aug 2026 10:0{uid}:00 +0000\r\n" f"Message-ID: \r\n" f"\r\n" f"Corps du message {uid}.\r\n" ).encode() @requires_servers class TestUnknown8bitDoesNotAbortSync(MailSandboxCase): """Bug 1 : `unknown encoding: unknown-8bit` arrêtait la sync d'un dossier pour de bon — le dossier échouait à chaque passe, donc son `last_uid` n'avançait jamais et l'utilisateur ne voyait plus rien arriver. Le risque couvert : un en-tête qu'aucun codec Python ne sait lire ne doit pas coûter le dossier. Ici l'étiquette arrive VRAIMENT du réseau, décodée par `imaplib` puis `parse_fetch_headers`, et non fabriquée par le test. """ def setUp(self): self.imap = self.imap_server() self.imap.folder("INBOX").deliver(HOSTILE, flags=["\\Seen"]) self.account = sandbox_account(imap_port=self.imap.port) self.store = self.temp_store(self.account) self.transport = self.imap_transport(self.imap, self.account) def sync(self): from script.todo.mail.imap_sync import Syncer return Syncer(self.store, self.transport).sync() def stored(self): state = self.store.folder_state("INBOX") return self.store.list_messages(state["id"]) def test_sync_reports_no_error(self): self.assertEqual(self.sync().errors, []) def test_the_message_is_stored(self): self.sync() self.assertEqual(len(self.stored()), 1) def test_the_unknown_charset_is_substituted_not_fatal(self): """`charset.decode_bytes` remplace ce qu'il ne sait pas lire. Sans ce garde-fou, `decode_header` lève `LookupError` et le message n'existe pas du tout.""" self.sync() subject = self.stored()[0].subject self.assertTrue(subject.startswith("sujet h")) self.assertIn("�", subject) def test_raw_8bit_header_bytes_survive_the_wire(self): """Le `From` part en octets 8 bits bruts, sans encodage : c'est ce que fait un vrai MTA relâché, et ce que Twisted refuse de reformater (voir `SandboxIMAP4Server.spew_body`).""" self.sync() self.assertIn("Lavall", self.stored()[0].frm) self.assertIn("rene@example.org", self.stored()[0].frm) def test_the_body_still_yields_a_snippet(self): """Le corps porte le même charset inconnu : ouvrir le message ne doit pas échouer non plus.""" from script.todo.mail.imap_sync import Syncer syncer = Syncer(self.store, self.transport) syncer.sync() raw = syncer.fetch_body("INBOX", 1) self.assertIn(b"Bonjour", raw) self.assertIn("Bonjour", self.stored()[0].snippet) def test_the_bytes_are_served_verbatim(self): """Le corps rendu par le serveur est OCTET POUR OCTET celui déclaré : si le bac à sable réécrivait quoi que ce soit, il ne prouverait plus rien sur le vrai réseau.""" from script.todo.mail.imap_sync import Syncer syncer = Syncer(self.store, self.transport) syncer.sync() self.assertEqual(syncer.fetch_body("INBOX", 1), HOSTILE) @requires_servers class TestWhatLeavesBySmtp(MailSandboxCase): """Le risque couvert : le Cci ne doit JAMAIS apparaître dans un en-tête. C'est la propriété de sécurité que tout le plan protège, et jusqu'ici elle n'était vérifiée que sur l'objet `EmailMessage` que nous tenions en main. Ici on l'affirme sur les OCTETS reçus à l'autre bout de la socket — ce que le serveur a vraiment lu. """ def setUp(self): from script.todo.mail import smtp_send self.smtp = self.smtp_server() self.account = sandbox_account( smtp_port=self.smtp.port, address="moi@example.ca", display_name="Mathieu Benoit", ) self.transport = smtp_send.connect(self.account, PASSWORD) self.addCleanup(self.transport.quit) self.msg = smtp_send.build_message( self.account, ["alice@example.org"], "Devis daté d'août", "Bonjour Alice", cc=["copie@example.org"], bcc=["cache@example.org"], date="Fri, 01 Aug 2026 10:41:00 +0000", msgid="", ) self.served = smtp_send.send(self.account, self.msg, self.transport) @property def sent(self): return self.smtp.messages[0] def test_the_server_received_exactly_one_message(self): self.assertEqual(len(self.smtp.messages), 1) def test_bcc_is_absent_from_the_bytes_that_left(self): self.assertNotIn(b"cache@example.org", self.sent.content) self.assertNotIn(b"Bcc", self.sent.content) self.assertNotIn(b"X-ERPLibre-Bcc", self.sent.content) def test_bcc_is_absent_from_every_header(self): parsed = self.sent.headers() self.assertIsNone(parsed.get("Bcc")) self.assertIsNone(parsed.get("X-ERPLibre-Bcc")) def test_bcc_is_served_by_the_envelope(self): """Caché ne veut pas dire non livré : le Cci ne vit que dans l'enveloppe SMTP, que le serveur nous rend ici telle qu'il l'a reçue.""" self.assertIn("cache@example.org", self.sent.rcpt_tos) self.assertEqual( sorted(self.sent.rcpt_tos), sorted( ["alice@example.org", "copie@example.org", "cache@example.org"] ), ) def test_the_envelope_sender_is_the_account(self): self.assertEqual(self.sent.mail_from, "moi@example.ca") def test_send_reports_the_recipients_it_served(self): self.assertEqual(sorted(self.served), sorted(self.sent.rcpt_tos)) def test_nothing_8bit_left_on_the_wire(self): """Un en-tête accentué doit partir encodé RFC 2047. Sorti brut, il serait mutilé par le premier relais venu — et c'est exactement le genre d'octet qui nous est revenu en `unknown-8bit`.""" headers = self.sent.content.split(b"\r\n\r\n", 1)[0] headers.decode("ascii") # lève si un octet 8 bits a fui def test_the_accented_subject_arrives_intact(self): self.assertEqual( self.sent.headers()["Subject"], "Devis =?utf-8?q?dat=C3=A9_d=27ao=C3=BBt?=", ) from script.todo.mail.imap_transport import decode_header_value self.assertEqual( decode_header_value(self.sent.headers()["Subject"]), "Devis daté d'août", ) def test_the_visible_headers_are_the_ones_we_built(self): parsed = self.sent.headers() self.assertEqual(parsed["From"], "Mathieu Benoit ") self.assertEqual(parsed["To"], "alice@example.org") self.assertEqual(parsed["Cc"], "copie@example.org") self.assertEqual(parsed["Message-ID"], "") @requires_servers class TestSmtpRefusals(MailSandboxCase): """Le risque couvert : un refus du serveur doit devenir une `SmtpError` lisible, jamais une exception brute remontée dans le TUI.""" def test_a_wrong_password_is_an_smtp_error(self): """Le serveur EXIGE l'authentification et la refuse pour de vrai — un 535 sur le fil, pas une exception injectée. La sous-classe ne change rien au protocole : elle ne sert qu'à retenir la connexion pour la fermer. `smtp_send.connect()` ne ferme pas sa socket quand `login()` échoue — elle traîne jusqu'au ramasse-miettes, ce qui est bénin dans le TUI mais laisserait ici un `ResourceWarning` attaché à un test au hasard. """ import smtplib from script.todo.mail.smtp_send import SmtpError, connect opened = [] class Recording(smtplib.SMTP): def __init__(inner, *args, **kwargs): super().__init__(*args, **kwargs) opened.append(inner) smtp = self.smtp_server(require_auth=True) account = sandbox_account(smtp_port=smtp.port) with unittest.mock.patch("smtplib.SMTP", Recording): with self.assertRaises(SmtpError): connect(account, "mauvais-mot-de-passe") for client in opened: client.close() def test_a_closed_port_is_an_smtp_error(self): """Le serveur est démarré puis tué : le port est fermé pour de bon, et personne d'autre n'écoute dessus.""" from script.todo.mail.smtp_send import SmtpError, connect smtp = self.smtp_server() port = smtp.port smtp.stop() account = sandbox_account(smtp_port=port) with self.assertRaises(SmtpError): connect(account, PASSWORD) @requires_servers class TestConnectionDroppedMidSync(MailSandboxCase): """Le risque couvert : une coupure en pleine passe ne doit ni faire tomber le TUI, ni PERDRE des messages en avançant `last_uid` sur des en-têtes jamais reçus. Aucun double ne coupe jamais la ligne : il répond toujours. """ def setUp(self): self.imap = self.imap_server() inbox = self.imap.folder("INBOX") for uid in (1, 2, 3): inbox.deliver(polite(uid), uid=uid) self.account = sandbox_account(imap_port=self.imap.port) self.store = self.temp_store(self.account) def syncer(self): from script.todo.mail.imap_sync import Syncer return Syncer(self.store, self.imap_transport(self.imap, self.account)) def test_a_drop_is_reported_not_raised(self): self.imap.fail(DropConnection(b"FETCH")) with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): report = self.syncer().sync() self.assertEqual(len(report.errors), 1) self.assertIn("INBOX", report.errors[0]) def test_a_drop_before_the_headers_loses_nothing(self): """La coupure tombe sur le tout premier FETCH : rien n'a été stocké, donc `last_uid` ne doit pas avoir bougé — sinon les trois messages seraient sautés pour toujours.""" self.imap.fail(DropConnection(b"FETCH")) with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): self.syncer().sync() state = self.store.folder_state("INBOX") self.assertEqual(state["last_uid"], 0) def test_the_next_pass_recovers_every_message(self): self.imap.fail(DropConnection(b"FETCH")) with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): self.syncer().sync() self.imap.faults.clear() report = self.syncer().sync() self.assertEqual(report.errors, []) self.assertEqual(report.new_messages, 3) state = self.store.folder_state("INBOX") self.assertEqual( sorted(m.uid for m in self.store.list_messages(state["id"])), [1, 2, 3], ) def test_a_drop_after_the_headers_keeps_what_arrived(self): """Cette fois la coupure tombe sur le FETCH des drapeaux, après que les en-têtes soient descendus : le travail déjà fait doit rester.""" self.imap.fail(DropConnection(b"FETCH", after=1)) with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): report = self.syncer().sync() self.assertEqual(report.new_messages, 3) state = self.store.folder_state("INBOX") self.assertEqual(len(self.store.list_messages(state["id"])), 3) def test_a_refused_folder_does_not_cost_the_others(self): """Un NO sur un dossier — droits, quota, boîte verrouillée — laisse le reste de la boîte utilisable. Ici le refus vient du serveur, pas d'une exception que le test aurait injectée.""" self.imap.folder("INBOX.Archive").deliver(polite(9), uid=9) self.imap.fail( RefuseCommand(b"SELECT", after=1, text=b"Mailbox locked") ) with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): report = self.syncer().sync() self.assertEqual(report.folders, 2) self.assertEqual(len(report.errors), 1) self.assertEqual(report.new_messages, 3) @requires_servers class TestSentCopyLandsOnTheServer(MailSandboxCase): """Bug 3 : la liste montrait un état périmé après un envoi. Le chemin réel est APPEND puis `sync_one` sur le dossier Envoyés. Le risque couvert : la copie doit exister CHEZ LE SERVEUR, sous l'UID que le serveur attribue — pas un UID inventé localement, qui entrerait en collision avec un futur message réel — et elle ne doit pas rouvrir la porte du Cci par ce second chemin. """ def setUp(self): from script.todo.mail import smtp_send self.imap = self.imap_server() self.sent_box = self.imap.folder("INBOX.Sent") self.sent_box.deliver(polite(1, "Un envoi précédent"), uid=1) self.account = sandbox_account( imap_port=self.imap.port, sent_folder="INBOX.Sent" ) self.store = self.temp_store(self.account) self.transport = self.imap_transport(self.imap, self.account) self.msg = smtp_send.build_message( self.account, ["alice@example.org"], "Copie classée", "Bonjour", bcc=["cache@example.org"], date="Fri, 01 Aug 2026 10:41:00 +0000", msgid="", ) def file_the_copy(self): """Exactement ce que fait `deliver()` dans `tui.py` : `without_bcc` puis APPEND, puis une sync ciblée.""" from script.todo.mail.imap_sync import Syncer from script.todo.mail.smtp_send import without_bcc self.transport.append( self.account.sent_folder, without_bcc(self.msg).as_bytes(), ["\\Seen"], ) return Syncer(self.store, self.transport).sync_one( self.account.sent_folder ) def test_the_server_accepted_the_append(self): self.file_the_copy() self.assertEqual(len(self.sent_box.appended), 1) def test_the_copy_the_server_stored_carries_no_bcc(self): """Assertion sur les octets DÉPOSÉS, pas sur notre objet en mémoire : c'est la seconde porte par laquelle le Cci pourrait fuir.""" self.file_the_copy() stored, _flags = self.sent_box.appended[0] self.assertNotIn(b"cache@example.org", stored) self.assertNotIn(b"X-ERPLibre-Bcc", stored) def test_the_flags_we_asked_for_reached_the_server(self): self.file_the_copy() _stored, flags = self.sent_box.appended[0] self.assertIn("\\Seen", flags) def test_the_copy_shows_up_in_the_list(self): report = self.file_the_copy() self.assertEqual(report.errors, []) state = self.store.folder_state("INBOX.Sent") subjects = {m.subject for m in self.store.list_messages(state["id"])} self.assertIn("Copie classée", subjects) def test_the_uid_comes_from_the_server(self): """La boîte contenait déjà un message : la copie doit porter l'UID 2, celui que le serveur a attribué.""" self.file_the_copy() state = self.store.folder_state("INBOX.Sent") stored = { m.subject: m.uid for m in self.store.list_messages(state["id"]) } self.assertEqual(stored["Copie classée"], 2) @requires_servers class TestFolderListing(MailSandboxCase): """Le risque couvert : les noms de dossiers viennent d'une réponse LIST réelle, avec son délimiteur, ses guillemets et son UTF-7 modifié, pas d'une chaîne que le test aurait écrite dans le format qui l'arrange. C'est ce que le bac à sable donne gratuitement : Twisted encode de lui-même le nom accentué en UTF-7 modifié (RFC 3501), un aller-retour qu'aucun double n'avait jamais fait faire à `decode_mailbox`. """ ACCENTED = "INBOX.Courriels envoyés" def setUp(self): self.imap = self.imap_server() for name in ("INBOX", "INBOX.Sent", self.ACCENTED): self.imap.folder(name).deliver(polite(1), uid=1) self.transport = self.imap_transport(self.imap) def test_every_folder_is_listed(self): self.assertEqual(len(self.transport.list_folders()), 3) def test_the_accented_name_arrives_in_modified_utf7(self): """Le nom BRUT est celui du fil — c'est lui qu'il faudra renvoyer au serveur pour sélectionner le dossier, pas sa version lisible.""" names = {f.name for f in self.transport.list_folders()} self.assertIn("INBOX.Courriels envoy&AOk-s", names) def test_the_accented_name_is_decoded_for_display(self): display = {f.name: f.display for f in self.transport.list_folders()} self.assertEqual(display["INBOX.Courriels envoy&AOk-s"], self.ACCENTED) def test_inbox_gets_its_role_from_its_name(self): roles = {f.name: f.role for f in self.transport.list_folders()} self.assertEqual(roles["INBOX"], "inbox") def test_no_special_use_role_is_announced(self): """LACUNE ASSUMÉE — bug 2 (le dossier Envoyés annoncé par le serveur) reste hors de portée : Twisted n'annonce que `IMAP4REV1 NAMESPACE IDLE`, sans `SPECIAL-USE`. Implémenter l'extension nous-mêmes reviendrait à tester notre propre supposition sur elle — précisément l'erreur que ce bac à sable existe pour éviter. Ce test verrouille la lacune au lieu de la maquiller : le jour où un serveur de test l'annoncera, il échouera et rappellera qu'il y a mieux à écrire.""" self.assertNotIn( "SPECIAL-USE", str(self.transport.client.capabilities).upper(), ) roles = {f.role for f in self.transport.list_folders()} self.assertEqual(roles - {"inbox"}, {None}) @requires_servers class TestSandboxLifecycle(MailSandboxCase): """Le risque couvert : une socket d'écoute oubliée ou un fil coincé empoisonneraient toute la suite. Ces tests-ci vérifient le bac à sable lui-même, pas le client.""" def test_ports_are_ephemeral_and_distinct(self): first, second = self.imap_server(), self.imap_server() self.assertNotEqual(first.port, 0) self.assertNotEqual(first.port, second.port) def test_a_failing_test_still_closes_its_servers(self): """La preuve de non-fuite : on fait ÉCHOUER un test à l'intérieur du test, et on vérifie que ses serveurs sont morts quand même.""" ports = {} class Doomed(MailSandboxCase): def runTest(inner): ports["imap"] = inner.imap_server().port ports["smtp"] = inner.smtp_server().port inner.fail("échec provoqué") before = set(LIVE_SERVERS) result = unittest.TestResult() Doomed().run(result) self.assertEqual(len(result.failures), 1) self.assertEqual(set(LIVE_SERVERS), before) self.assertTrue(port_is_closed(ports["imap"])) self.assertTrue(port_is_closed(ports["smtp"])) def test_an_open_client_session_does_not_survive_the_test(self): """`stopListening` seul cesse d'ACCEPTER : une session cliente restée ouverte garderait un descripteur vivant d'un test à l'autre.""" ports = {} class LeavesAClientBehind(MailSandboxCase): def runTest(inner): sandbox = inner.imap_server() sandbox.folder("INBOX") ports["imap"] = sandbox.port inner.imap_transport(sandbox) # jamais fermée par le test result = unittest.TestResult() LeavesAClientBehind().run(result) self.assertEqual(result.errors, []) self.assertTrue(port_is_closed(ports["imap"])) def test_only_one_reactor_thread_exists(self): """Le cœur du choix de conception : le réacteur Twisted ne se redémarre pas, donc il n'y en a qu'UN pour toute la session, quel que soit le nombre de serveurs démarrés.""" self.imap_server() self.imap_server() reactors = [ th for th in threading.enumerate() if th.name == "mail-sandbox-reactor" ] self.assertEqual(len(reactors), 1) def test_smtp_threads_do_not_pile_up(self): """`aiosmtpd` porte sa propre boucle asyncio dans un fil : chaque serveur arrêté doit rendre le sien.""" before = threading.active_count() for _ in range(3): self.smtp_server().stop() self.assertLessEqual(threading.active_count(), before) @requires_servers class TestSandboxServesWhatWasDeclared(MailSandboxCase): """Le bac à sable n'est utile que s'il ne réécrit rien. Si ces assertions-là tombent, tous les autres tests de ce fichier ne prouvent plus rien sur le vrai réseau.""" def selected(self, imap, folder="INBOX"): transport = self.imap_transport(imap) transport.select(folder) return transport def test_a_polite_message_goes_through_twisteds_own_path(self): """Par défaut le serveur est POLI : les en-têtes ASCII sont formatés par Twisted, pas par nous (voir `SandboxIMAP4Server.spew_body`).""" imap = self.imap_server() imap.folder("INBOX").deliver(polite(1, "Devis"), uid=1) headers = self.selected(imap).fetch_headers([1]) self.assertEqual(headers[0].subject, "Devis") self.assertEqual(headers[0].msgid, "") def test_flags_and_size_come_from_the_server(self): imap = self.imap_server() raw = polite(1) imap.folder("INBOX").deliver(raw, flags=["\\Seen"], uid=1) headers = self.selected(imap).fetch_headers([1]) self.assertIn("\\Seen", headers[0].flags) self.assertEqual(headers[0].size, len(raw)) def test_the_date_is_parsed_from_the_served_header(self): imap = self.imap_server() imap.folder("INBOX").deliver(polite(1), uid=1) headers = self.selected(imap).fetch_headers([1]) expected = int( email.utils.parsedate_to_datetime( "Wed, 06 Aug 2026 10:01:00 +0000" ).timestamp() ) self.assertEqual(headers[0].date, expected) def test_only_the_requested_uids_come_back(self): imap = self.imap_server() for uid in (1, 2, 3): imap.folder("INBOX").deliver(polite(uid), uid=uid) headers = self.selected(imap).fetch_headers([2]) self.assertEqual([h.uid for h in headers], [2]) def test_lf_only_line_endings_are_still_split_correctly(self): """`EmailMessage.as_bytes()` — ce que le client dépose par APPEND — termine ses lignes en LF SEUL, alors que le fil IMAP est en CRLF. Le message commence par un `Received` que le client ne demande pas, comme tout message ayant traversé un relais. Un découpage qui n'attendrait que du CRLF verrait UNE seule ligne, nommée `Received`, donc hors du filtre : le bloc rendu serait VIDE, sans rien lever. L'octet 8 bits dans `From` force le chemin verbatim, le seul où ce découpage sert. """ imap = self.imap_server() raw = ( "Received: from relais.example.org by example.ca; " "Wed, 06 Aug 2026 10:01:00 +0000\n" "From: Ren\xe9 \n" "To: moi@example.ca\n" "Subject: Devis\n" "Date: Wed, 06 Aug 2026 10:01:00 +0000\n" "Message-ID: \n" "\n" "Corps.\n" ).encode("latin-1") imap.folder("INBOX").deliver(raw, uid=1) headers = self.selected(imap).fetch_headers([1]) self.assertEqual(headers[0].msgid, "") self.assertEqual(headers[0].subject, "Devis") self.assertIn("rene@example.org", headers[0].frm) def test_search_only_returns_uids_at_or_above_the_mark(self): imap = self.imap_server() for uid in (1, 2, 3): imap.folder("INBOX").deliver(polite(uid), uid=uid) self.assertEqual(self.selected(imap).search_uids(2), [2, 3]) def test_store_flags_reaches_the_server(self): imap = self.imap_server() box = imap.folder("INBOX") box.deliver(polite(1), uid=1) transport = self.selected(imap) transport.store_flags(1, ["\\Seen"], []) self.assertEqual(transport.fetch_flags([1]), [(1, "\\Seen")]) self.assertEqual(box.messages[0].flags, ["\\Seen"]) if __name__ == "__main__": unittest.main()