Repository: twisted/twisted

Issue:
twisted.mail.mail.MailService.addDomain does not register its credential checker, and thus does not allow authenticating to the domain
Originally reported in #11870 

Discussion:

Patch:
diff --git a/src/twisted/cred/credentials.py b/src/twisted/cred/credentials.py
index 662913951c..b2e9013c09 100644
--- a/src/twisted/cred/credentials.py
+++ b/src/twisted/cred/credentials.py
@@ -7,7 +7,7 @@ This module defines L{ICredentials}, an interface for objects that represent
 authentication credentials to provide, and also includes a number of useful
 implementations of that interface.
 """
-
+from __future__ import annotations
 
 import base64
 import hmac
@@ -17,10 +17,11 @@ import time
 from binascii import hexlify
 from hashlib import md5
 
-from zope.interface import Interface, implementer
+from zope.interface import Attribute, Interface, implementer
 
 from twisted.cred import error
 from twisted.cred._digest import calcHA1, calcHA2, calcResponse
+from twisted.internet.defer import Deferred
 from twisted.python.compat import nativeString, networkString
 from twisted.python.deprecate import deprecatedModuleAttribute
 from twisted.python.randbytes import secureRandom
@@ -61,11 +62,14 @@ class IUsernameHashedPassword(ICredentials):
     kind of credential must store the passwords in plaintext (or as
     password-equivalent hashes) form so that they can be hashed in a manner
     appropriate for the particular credentials class.
-
-    @type username: L{bytes}
-    @ivar username: The username associated with these credentials.
     """
 
+    username: bytes = Attribute(
+        """
+        The username associated with these credentials.
+        """
+    )
+
     def checkPassword(password):
         """
         Validate these credentials against the correct password.
@@ -101,18 +105,16 @@ class IUsernamePassword(ICredentials):
     username: bytes
     password: bytes
 
-    def checkPassword(password: bytes) -> bool:
+    def checkPassword(password: bytes) -> bool | Deferred[bool]:
         """
         Validate these credentials against the correct password.
 
-        @type password: L{bytes}
         @param password: The correct, plaintext password against which to
-        check.
+            check.
 
-        @rtype: C{bool} or L{Deferred}
-        @return: C{True} if the credentials represented by this object match the
-            given password, C{False} if they do not, or a L{Deferred} which will
-            be called back with one of these values.
+        @return: C{True} if the credentials represented by this object match
+            the given password, C{False} if they do not, or a L{Deferred} which
+            will be called back with one of these values.
         """
 
 
@@ -462,6 +464,14 @@ class UsernameHashedPassword:
 
 @implementer(IUsernamePassword)
 class UsernamePassword:
+    """
+    A trivial implementation of L{IUsernamePassword}, containing a username and
+    a password.
+    """
+
+    username: bytes
+    password: bytes
+
     def __init__(self, username: bytes, password: bytes) -> None:
         self.username = username
         self.password = password
diff --git a/src/twisted/mail/interfaces.py b/src/twisted/mail/interfaces.py
index dd87d35a63..0bb78e1bfa 100644
--- a/src/twisted/mail/interfaces.py
+++ b/src/twisted/mail/interfaces.py
@@ -6,10 +6,14 @@ Interfaces for L{twisted.mail}.
 
 @since: 16.5
 """
+from __future__ import annotations
 
+from typing import overload
 
 from zope.interface import Interface
 
+from twisted.cred.portal import IRealm
+
 
 class IChallengeResponse(Interface):
     """
@@ -138,7 +142,15 @@ class IMailboxPOP3(Interface):
         remain on the server before being deleted.
     """
 
-    def listMessages(index=None):
+    @overload
+    def listMessages() -> list[int]:
+        ...
+
+    @overload
+    def listMessages(i: int) -> int:
+        ...
+
+    def listMessages(i: int | None = None) -> int | list[int]:
         """
         Retrieve the size of a message, or, if none is specified, the size of
         each message in the mailbox.
@@ -215,7 +227,7 @@ class IMailboxPOP3(Interface):
         """
 
 
-class IDomain(Interface):
+class IDomain(IRealm):
     """
     An interface for email domains.
     """
diff --git a/src/twisted/mail/mail.py b/src/twisted/mail/mail.py
index 2dc405344b..bf84ae2f3e 100644
--- a/src/twisted/mail/mail.py
+++ b/src/twisted/mail/mail.py
@@ -5,22 +5,20 @@
 """
 Mail service support.
 """
+from __future__ import annotations
 
 # System imports
 import os
 import warnings
+from typing import NoReturn
 
-from zope.interface import implementer
+from zope.interface import Interface, implementer
 
 from twisted.application import internet, service
-from twisted.cred.portal import Portal
-
-# Twisted imports
+from twisted.cred.portal import IRealm, Portal
 from twisted.internet import defer
-
-# Sibling imports
 from twisted.mail import protocols, smtp
-from twisted.mail.interfaces import IAliasableDomain, IDomain
+from twisted.mail.interfaces import IAlias, IAliasableDomain, IDomain
 from twisted.python import log, util
 
 
@@ -396,6 +394,14 @@ class BounceDomain:
         """
         return []
 
+    def requestAvatar(
+        self, avatarId: bytes | tuple[()], mind: object, *interfaces: type[Interface]
+    ) -> NoReturn:
+        """
+        Bounce domains cannot authenticate users.
+        """
+        raise NotImplementedError()
+
 
 @implementer(smtp.IMessage)
 class FileMessage:
@@ -453,6 +459,7 @@ class FileMessage:
         os.remove(self.name)
 
 
+@implementer(IRealm)
 class MailService(service.MultiService):
     """
     An email service.
@@ -466,11 +473,8 @@ class MailService(service.MultiService):
     @type portals: L{dict} of L{bytes} -> L{Portal}
     @ivar portals: A mapping of domain name to authentication portal.
 
-    @type aliases: L{None} or L{dict} of
-        L{bytes} -> L{IAlias} provider
     @ivar aliases: A mapping of domain name to alias.
 
-    @type smtpPortal: L{Portal}
     @ivar smtpPortal: A portal for authentication for the SMTP server.
 
     @type monitor: L{FileMonitoringService}
@@ -478,19 +482,17 @@ class MailService(service.MultiService):
     """
 
     queue = None
-    domains = None
-    portals = None
-    aliases = None
-    smtpPortal = None
+    aliases: dict[bytes, IAlias] | None = None
+    smtpPortal: Portal
 
-    def __init__(self):
+    def __init__(self) -> None:
         """
         Initialize the mail service.
         """
         service.MultiService.__init__(self)
         # Domains and portals for "client" protocols - POP3, IMAP4, etc
         self.domains = DomainWithDefaultDict({}, BounceDomain())
-        self.portals = {}
+        self.portals: dict[bytes, Portal] = {}
 
         self.monitor = FileMonitoringService()
         self.monitor.setServiceParent(self)
@@ -523,18 +525,17 @@ class MailService(service.MultiService):
         """
         return protocols.ESMTPFactory(self, self.smtpPortal)
 
-    def addDomain(self, name, domain):
+    def addDomain(self, name: bytes, domain: IDomain) -> None:
         """
         Add a domain for which the service will accept email.
 
-        @type name: L{bytes}
         @param name: A domain name.
 
-        @type domain: L{IDomain} provider
         @param domain: A domain object.
         """
         portal = Portal(domain)
-        map(portal.registerChecker, domain.getCredentialsCheckers())
+        for checker in domain.getCredentialsCheckers():
+            portal.registerChecker(checker)
         self.domains[name] = domain
         self.portals[name] = portal
         if self.aliases and IAliasableDomain.providedBy(domain):
diff --git a/src/twisted/mail/maildir.py b/src/twisted/mail/maildir.py
index c58bf31a94..aca2f4f851 100644
--- a/src/twisted/mail/maildir.py
+++ b/src/twisted/mail/maildir.py
@@ -6,20 +6,26 @@
 """
 Maildir-style mailbox support.
 """
+from __future__ import annotations
 
 import io
 import os
 import socket
 import stat
 from hashlib import md5
-from typing import IO
+from typing import IO, Callable, overload
 
-from zope.interface import implementer
+from zope.interface import Interface, implementer
 
 from twisted.cred import checkers, credentials, portal
+from twisted.cred.credentials import IUsernameHashedPassword, IUsernamePassword
 from twisted.cred.error import UnauthorizedLogin
 from twisted.internet import defer, interfaces, reactor
+from twisted.internet.defer import Deferred, succeed
+from twisted.internet.interfaces import IProducer
 from twisted.mail import mail, pop3, smtp
+from twisted.mail.alias import AliasBase
+from twisted.mail.mail import MailService
 from twisted.persisted import dirdbm
 from twisted.protocols import basic
 from twisted.python import failure, log
@@ -60,7 +66,7 @@ class _MaildirNameGenerator:
         """
         self._clock = clock
 
-    def generate(self):
+    def generate(self) -> bytes:
         """
         Generate a string which is intended to be unique across all calls to
         this function (across all processes, reboots, etc).
@@ -76,28 +82,31 @@ class _MaildirNameGenerator:
         t = self._clock.seconds()
         seconds = str(int(t))
         microseconds = "%07d" % (int((t - int(t)) * 10e6),)
-        return f"{seconds}.M{microseconds}P{self.p}Q{self.n}.{self.s}"
+        return os.fsencode(f"{seconds}.M{microseconds}P{self.p}Q{self.n}.{self.s}")
 
 
 _generateMaildirName = _MaildirNameGenerator(reactor).generate
 
 
-def initializeMaildir(dir):
+def initializeMaildir(dir: bytes | str) -> None:
     """
     Create a maildir user directory if it doesn't already exist.
 
-    @type dir: L{bytes}
     @param dir: The path name for a user directory.
     """
-    dir = os.fsdecode(dir)
-    if not os.path.isdir(dir):
-        os.mkdir(dir, 0o700)
-        for subdir in ["new", "cur", "tmp", ".Trash"]:
-            os.mkdir(os.path.join(dir, subdir), 0o700)
-        for subdir in ["new", "cur", "tmp"]:
-            os.mkdir(os.path.join(dir, ".Trash", subdir), 0o700)
+    bdir: bytes
+    if isinstance(dir, bytes):
+        bdir = dir
+    else:
+        bdir = os.fsencode(dir)
+    if not os.path.isdir(bdir):
+        os.mkdir(bdir, 0o700)
+        for subdir in [b"new", b"cur", b"tmp", b".Trash"]:
+            os.mkdir(os.path.join(bdir, subdir), 0o700)
+        for subdir in [b"new", b"cur", b"tmp"]:
+            os.mkdir(os.path.join(bdir, b".Trash", subdir), 0o700)
         # touch
-        open(os.path.join(dir, ".Trash", "maildirfolder"), "w").close()
+        open(os.path.join(bdir, b".Trash", b"maildirfolder"), "w").close()
 
 
 class MaildirMessage(mail.FileMessage):
@@ -160,22 +169,17 @@ class AbstractMaildirDomain:
     """
     An abstract maildir-backed domain.
 
-    @type alias: L{None} or L{dict} mapping
-        L{bytes} to L{AliasBase}
     @ivar alias: A mapping of username to alias.
 
     @ivar root: See L{__init__}.
     """
 
-    alias = None
-    root = None
+    alias: None | dict[bytes, AliasBase] = None
 
-    def __init__(self, service, root):
+    def __init__(self, service: MailService, root: bytes) -> None:
         """
-        @type service: L{MailService}
         @param service: An email service.
 
-        @type root: L{bytes}
         @param root: The maildir root directory.
         """
         self.root = root
@@ -274,16 +278,14 @@ class AbstractMaildirDomain:
         """
         return False
 
-    def addUser(self, user, password):
+    def addUser(self, user: bytes, password: bytes) -> None:
         """
         Add a user to this domain.
 
         Subclasses should override this method.
 
-        @type user: L{bytes}
         @param user: A username.
 
-        @type password: L{bytes}
         @param password: A password.
         """
         raise NotImplementedError
@@ -300,6 +302,14 @@ class AbstractMaildirDomain:
         """
         raise NotImplementedError
 
+    def requestAvatar(
+        self, avatarId: bytes | tuple[()], mind: object, *interfaces: type[Interface]
+    ) -> tuple[type[Interface], object, Callable[[], None]]:
+        """
+        Abstract domains cannot authenticate users.
+        """
+        raise NotImplementedError()
+
 
 @implementer(interfaces.IConsumer)
 class _MaildirMailboxAppendMessageTask:
@@ -342,7 +352,7 @@ class _MaildirMailboxAppendMessageTask:
     osclose = staticmethod(os.close)
     osrename = staticmethod(os.rename)
 
-    def __init__(self, mbox, msg):
+    def __init__(self, mbox: MaildirMailbox, msg: bytes | IO[bytes]) -> None:
         """
         @type mbox: L{MaildirMailbox}
         @param mbox: A maildir mailbox.
@@ -351,13 +361,13 @@ class _MaildirMailboxAppendMessageTask:
         @param msg: The message to add.
         """
         self.mbox = mbox
-        self.defer = defer.Deferred()
+        self.defer: defer.Deferred[None] = defer.Deferred()
         self.openCall = None
         if not hasattr(msg, "read"):
             msg = io.BytesIO(msg)
         self.msg = msg
 
-    def startUp(self):
+    def startUp(self) -> None:
         """
         Start transferring the message to the mailbox.
         """
@@ -366,15 +376,13 @@ class _MaildirMailboxAppendMessageTask:
             self.filesender = basic.FileSender()
             self.filesender.beginFileTransfer(self.msg, self)
 
-    def registerProducer(self, producer, streaming):
+    def registerProducer(self, producer: IProducer, streaming: bool) -> None:
         """
         Register a producer and start asking it for data if it is
         non-streaming.
 
-        @type producer: L{IProducer <interfaces.IProducer>}
         @param producer: A producer.
 
-        @type streaming: L{bool}
         @param streaming: A flag indicating whether the producer provides a
             streaming interface.
         """
@@ -401,11 +409,10 @@ class _MaildirMailboxAppendMessageTask:
         self.osclose(self.fh)
         self.moveFileToNew()
 
-    def write(self, data):
+    def write(self, data: bytes) -> None:
         """
         Write data to the maildir file.
 
-        @type data: L{bytes}
         @param data: Data to be written to the file.
         """
         try:
@@ -434,7 +441,7 @@ class _MaildirMailboxAppendMessageTask:
         successfully.
         """
         while True:
-            newname = os.path.join(self.mbox.path, "new", _generateMaildirName())
+            newname = os.path.join(self.mbox.path, b"new", _generateMaildirName())
             try:
                 self.osrename(self.tmpname, newname)
                 break
@@ -466,7 +473,7 @@ class _MaildirMailboxAppendMessageTask:
         tries = 0
         self.fh = -1
         while True:
-            self.tmpname = os.path.join(self.mbox.path, "tmp", _generateMaildirName())
+            self.tmpname = os.path.join(self.mbox.path, b"tmp", _generateMaildirName())
             try:
                 self.fh = self.osopen(self.tmpname, attr, 0o600)
                 return None
@@ -488,38 +495,40 @@ class MaildirMailbox(pop3.Mailbox):
 
     @ivar path: See L{__init__}.
 
-    @type list: L{list} of L{int} or 2-L{tuple} of (0) file-like object,
-        (1) L{bytes}
-    @ivar list: Information about the messages in the mailbox. For undeleted
-        messages, the file containing the message and the
-        full path name of the file are stored.  Deleted messages are indicated
-        by 0.
+    @ivar list: Information about the messages in the mailbox.  For undeleted
+        messages, the full path name of the message storing the file is stored.
+        Deleted messages are indicated by 0.
 
-    @type deleted: L{dict} mapping 2-L{tuple} of (0) file-like object,
-        (1) L{bytes} to L{bytes}
-    @type deleted: A mapping of the information about a file before it was
+    @type deleted: A mapping of the path to a deleted file before it was
         deleted to the full path name of the deleted file in the I{.Trash/}
         subfolder.
     """
 
     AppendFactory = _MaildirMailboxAppendMessageTask
 
-    def __init__(self, path):
+    def __init__(self, path: bytes) -> None:
         """
-        @type path: L{bytes}
         @param path: The directory name for a maildir mailbox.
         """
         self.path = path
-        self.list = []
-        self.deleted = {}
+        self.deleted: dict[bytes, bytes] = {}
         initializeMaildir(path)
-        for name in ("cur", "new"):
+        computing = []
+        for name in (b"cur", b"new"):
             for file in os.listdir(os.path.join(path, name)):
-                self.list.append((file, os.path.join(path, name, file)))
-        self.list.sort()
-        self.list = [e[1] for e in self.list]
+                computing.append((file, os.path.join(path, name, file)))
+        computing.sort()
+        self.list: list[int | bytes] = [el[1] for el in computing]
 
-    def listMessages(self, i=None):
+    @overload
+    def listMessages(self) -> list[int]:
+        ...
+
+    @overload
+    def listMessages(self, i: int) -> int:
+        ...
+
+    def listMessages(self, i: int | None = None) -> int | list[int]:
         """
         Retrieve the size of a message, or, if none is specified, the size of
         each message in the mailbox.
@@ -546,7 +555,7 @@ class MaildirMailbox(pop3.Mailbox):
             return ret
         return self.list[i] and os.stat(self.list[i])[stat.ST_SIZE] or 0
 
-    def getMessage(self, i):
+    def getMessage(self, i: int) -> IO[str]:
         """
         Retrieve a file-like object with the contents of a message.
 
@@ -624,16 +633,14 @@ class MaildirMailbox(pop3.Mailbox):
                     self.list.append(real)
         self.deleted.clear()
 
-    def appendMessage(self, txt):
+    def appendMessage(self, txt: bytes | IO[bytes]) -> Deferred[None]:
         """
         Add a message to the mailbox.
 
-        @type txt: L{bytes} or file-like object
         @param txt: A message to add.
 
-        @rtype: L{Deferred <defer.Deferred>}
-        @return: A deferred which fires when the message has been added to
-            the mailbox.
+        @return: A deferred which fires when the message has been added to the
+            mailbox.
         """
         task = self.AppendFactory(self, txt)
         result = task.defer
@@ -759,18 +766,17 @@ class MaildirDirdbmDomain(AbstractMaildirDomain):
     portal = None
     _credcheckers = None
 
-    def __init__(self, service, root, postmaster=0):
+    def __init__(
+        self, service: MailService, root: bytes, postmaster: bool = False
+    ) -> None:
         """
-        @type service: L{MailService}
         @param service: An email service.
 
-        @type root: L{bytes}
         @param root: The maildir root directory.
 
-        @type postmaster: L{bool}
         @param postmaster: A flag indicating whether non-existent addresses
-            should be forwarded to the postmaster (C{True}) or
-            bounced (C{False}).
+            should be forwarded to the postmaster (C{True}) or bounced
+            (C{False}).
         """
         root = os.fsencode(root)
         AbstractMaildirDomain.__init__(self, service, root)
@@ -780,14 +786,12 @@ class MaildirDirdbmDomain(AbstractMaildirDomain):
         self.dbm = dirdbm.open(dbm)
         self.postmaster = postmaster
 
-    def userDirectory(self, name):
+    def userDirectory(self, name: bytes) -> bytes | None:
         """
         Return the path to a user's mail directory.
 
-        @type name: L{bytes}
         @param name: A username.
 
-        @rtype: L{bytes} or L{None}
         @return: The path to the user's mail directory for a valid user. For
             an invalid user, the path to the postmaster's mailbox if bounces
             are redirected there. Otherwise, L{None}.
@@ -795,13 +799,13 @@ class MaildirDirdbmDomain(AbstractMaildirDomain):
         if name not in self.dbm:
             if not self.postmaster:
                 return None
-            name = "postmaster"
+            name = b"postmaster"
         dir = os.path.join(self.root, name)
         if not os.path.exists(dir):
             initializeMaildir(dir)
         return dir
 
-    def addUser(self, user, password):
+    def addUser(self, user: bytes, password: bytes) -> None:
         """
         Add a user to this domain by adding an entry in the authentication
         database and initializing the user's mail directory.
@@ -828,7 +832,12 @@ class MaildirDirdbmDomain(AbstractMaildirDomain):
             self._credcheckers = [DirdbmDatabase(self.dbm)]
         return self._credcheckers
 
-    def requestAvatar(self, avatarId, mind, *interfaces):
+    def requestAvatar(
+        self,
+        avatarId: bytes | tuple[()],
+        mind: object,
+        *interfaces: type[Interface],
+    ) -> tuple[type[Interface], object, Callable[[], None]]:
         """
         Get the mailbox for an authenticated user.
 
@@ -859,11 +868,15 @@ class MaildirDirdbmDomain(AbstractMaildirDomain):
         """
         if pop3.IMailbox not in interfaces:
             raise NotImplementedError("No interface")
+        mbox: pop3.IMailbox
         if avatarId == checkers.ANONYMOUS:
             mbox = StringListMailbox([INTERNAL_ERROR])
         else:
-            mbox = MaildirMailbox(os.path.join(self.root, avatarId))
-
+            assert isinstance(
+                avatarId, bytes
+            ), "avatar ID must be bytes, already checked ANONYMOUS"
+            mboxroot = os.path.join(self.root, avatarId)
+            mbox = MaildirMailbox(mboxroot)
         return (pop3.IMailbox, mbox, lambda: None)
 
 
@@ -873,7 +886,6 @@ class DirdbmDatabase:
     A credentials checker which authenticates users out of a
     L{DirDBM <dirdbm.DirDBM>} database.
 
-    @type dirdbm: L{DirDBM <dirdbm.DirDBM>}
     @ivar dirdbm: An authentication database.
     """
 
@@ -883,14 +895,15 @@ class DirdbmDatabase:
         credentials.IUsernameHashedPassword,
     )
 
-    def __init__(self, dbm):
+    def __init__(self, dbm: dirdbm.DirDBM) -> None:
         """
-        @type dbm: L{DirDBM <dirdbm.DirDBM>}
         @param dbm: An authentication database.
         """
         self.dirdbm = dbm
 
-    def requestAvatarId(self, c):
+    def requestAvatarId(
+        self, c: IUsernamePassword | IUsernameHashedPassword
+    ) -> Deferred[bytes | tuple[()]]:
         """
         Authenticate a user and, if successful, return their username.
 
@@ -905,6 +918,7 @@ class DirdbmDatabase:
         @raise UnauthorizedLogin: When the credentials check fails.
         """
         if c.username in self.dirdbm:
-            if c.checkPassword(self.dirdbm[c.username]):
-                return c.username
+            password = self.dirdbm[c.username]
+            if c.checkPassword(password):
+                return succeed(c.username)
         raise UnauthorizedLogin()
diff --git a/src/twisted/mail/newsfragments/12431.bugfix b/src/twisted/mail/newsfragments/12431.bugfix
new file mode 100644
index 0000000000..1be068ce2a
--- /dev/null
+++ b/src/twisted/mail/newsfragments/12431.bugfix
@@ -0,0 +1,2 @@
+twisted.mail.mail.MailService.addDomain now adds the given domain's credential
+checkers to the service's portal, allowing users to authenticate to the domain.
diff --git a/src/twisted/mail/pop3.py b/src/twisted/mail/pop3.py
index 7b230d2059..8b4405cb3b 100644
--- a/src/twisted/mail/pop3.py
+++ b/src/twisted/mail/pop3.py
@@ -10,12 +10,13 @@ Post-office Protocol version 3.
 @author: Glyph Lefkowitz
 @author: Jp Calderone
 """
+from __future__ import annotations
 
 import base64
 import binascii
 import warnings
 from hashlib import md5
-from typing import Optional
+from typing import IO, Optional, overload
 
 from zope.interface import implementer
 
@@ -1330,7 +1331,15 @@ class Mailbox:
     A base class for mailboxes.
     """
 
-    def listMessages(self, i=None):
+    @overload
+    def listMessages(self) -> list[int]:
+        ...
+
+    @overload
+    def listMessages(self, i: int) -> int:
+        ...
+
+    def listMessages(self, i: int | None = None) -> int | list[int]:
         """
         Retrieve the size of a message, or, if none is specified, the size of
         each message in the mailbox.
@@ -1350,7 +1359,7 @@ class Mailbox:
         """
         return []
 
-    def getMessage(self, i):
+    def getMessage(self, i: int) -> IO[str]:
         """
         Retrieve a file containing the contents of a message.
 
@@ -1365,7 +1374,7 @@ class Mailbox:
         """
         raise ValueError
 
-    def getUidl(self, i):
+    def getUidl(self, i: int) -> bytes:
         """
         Get a unique identifier for a message.
 
@@ -1381,7 +1390,7 @@ class Mailbox:
         """
         raise ValueError
 
-    def deleteMessage(self, i):
+    def deleteMessage(self, i: int) -> None:
         """
         Mark a message for deletion.
 
@@ -1397,7 +1406,7 @@ class Mailbox:
         """
         raise ValueError
 
-    def undeleteMessages(self):
+    def undeleteMessages(self) -> None:
         """
         Undelete all messages marked for deletion.
 
@@ -1406,7 +1415,7 @@ class Mailbox:
         """
         pass
 
-    def sync(self):
+    def sync(self) -> None:
         """
         Discard the contents of any message marked for deletion.
         """
diff --git a/src/twisted/mail/protocols.py b/src/twisted/mail/protocols.py
index 7bd1eebbee..035a1b0c12 100644
--- a/src/twisted/mail/protocols.py
+++ b/src/twisted/mail/protocols.py
@@ -5,7 +5,9 @@
 """
 Mail protocol support.
 """
+from __future__ import annotations
 
+from typing import TYPE_CHECKING, Callable
 
 from zope.interface import implementer
 
@@ -16,6 +18,9 @@ from twisted.internet import defer, protocol
 from twisted.mail import pop3, relay, smtp
 from twisted.python import log
 
+if TYPE_CHECKING:
+    from twisted.mail.mail import MailService
+
 
 @implementer(smtp.IMessageDelivery)
 class DomainDeliveryBase:
@@ -250,18 +255,16 @@ class VirtualPOP3(pop3.POP3):
     """
     A virtual hosting POP3 server.
 
-    @type service: L{MailService}
     @ivar service: The email service that created this server.  This must be
         set by the service.
 
-    @type domainSpecifier: L{bytes}
     @ivar domainSpecifier: The character to use to split an email address into
         local-part and domain. The default is '@'.
     """
 
-    service = None
+    service: MailService | None = None
 
-    domainSpecifier = b"@"  # Gaagh! I hate POP3. No standardized way
+    domainSpecifier: bytes = b"@"  # Gaagh! I hate POP3. No standardized way
     # to indicate user@host. '@' doesn't work
     # with NS, e.g.
 
@@ -296,21 +299,18 @@ class VirtualPOP3(pop3.POP3):
                 pop3.APOPCredentials(self.magic, user, digest), None, pop3.IMailbox
             )
 
-    def authenticateUserPASS(self, user, password):
+    def authenticateUserPASS(
+        self, user: bytes, password: bytes
+    ) -> defer.Deferred[tuple[type[pop3.IMailbox], pop3.IMailbox, Callable[[], None]]]:
         """
         Perform authentication for a username/password login.
 
         Override the default lookup scheme to allow virtual domains.
 
-        @type user: L{bytes}
         @param user: The name of the user attempting to log in.
 
-        @type password: L{bytes}
         @param password: The password to authenticate with.
 
-        @rtype: L{Deferred} which successfully results in 3-L{tuple} of
-            (L{IMailbox <pop3.IMailbox>}, L{IMailbox <pop3.IMailbox>}
-            provider, no-argument callable)
         @return: A deferred which fires when authentication is complete.
             If successful, it returns an L{IMailbox <pop3.IMailbox>} interface,
             a mailbox and a logout function. If authentication fails, the
@@ -318,24 +318,28 @@ class VirtualPOP3(pop3.POP3):
             <twisted.cred.error.UnauthorizedLogin>} error.
         """
         user, domain = self.lookupDomain(user)
+        assert (
+            self.service is not None
+        ), "must have a service to be able to authenticate"
         try:
             portal = self.service.lookupPortal(domain)
         except KeyError:
             return defer.fail(UnauthorizedLogin())
         else:
-            return portal.login(UsernamePassword(user, password), None, pop3.IMailbox)
+            result: defer.Deferred[
+                tuple[type[pop3.IMailbox], pop3.IMailbox, Callable[[], None]]
+            ] = portal.login(UsernamePassword(user, password), None, pop3.IMailbox)
+            return result
 
-    def lookupDomain(self, user):
+    def lookupDomain(self, user: bytes) -> tuple[bytes, bytes]:
         """
-        Check whether a domain is among the virtual domains supported by the
-        mail service.
+        Check whether a domain part of the given email address is among the
+        virtual domains supported by the mail service.
 
-        @type user: L{bytes}
         @param user: An email address.
 
-        @rtype: 2-L{tuple} of (L{bytes}, L{bytes})
-        @return: The local part and the domain part of the email address if the
-            domain is supported.
+        @return: a 2-tuple of (local part, domain part) of the email address if
+            the domain is supported.
 
         @raise POP3Error: When the domain is not supported by the mail service.
         """
@@ -343,6 +347,7 @@ class VirtualPOP3(pop3.POP3):
             user, domain = user.split(self.domainSpecifier, 1)
         except ValueError:
             domain = b""
+        assert self.service is not None, "cannot look up domain if service not set"
         if domain not in self.service.domains:
             raise pop3.POP3Error("no such domain {}".format(domain.decode("utf-8")))
         return user, domain
diff --git a/src/twisted/persisted/dirdbm.py b/src/twisted/persisted/dirdbm.py
index da8375b77c..e9bdc560f5 100644
--- a/src/twisted/persisted/dirdbm.py
+++ b/src/twisted/persisted/dirdbm.py
@@ -3,34 +3,30 @@
 # Copyright (c) Twisted Matrix Laboratories.
 # See LICENSE for details.
 
-
 """
-DBM-style interface to a directory.
+A L{DirDBM} is a L{dbm}-style interface to a directory.
 
 Each key is stored as a single file.  This is not expected to be very fast or
 efficient, but it's good for easy debugging.
 
-DirDBMs are *not* thread-safe, they should only be accessed by one thread at
+L{DirDBM}s are *not* thread-safe, they should only be accessed by one thread at
 a time.
 
 No files should be placed in the working directory of a DirDBM save those
 created by the DirDBM itself!
-
-Maintainer: Itamar Shtull-Trauring
 """
 
+from __future__ import annotations
 
 import base64
 import glob
 import os
 import pickle
+from typing import AnyStr, Iterable, Mapping, TypeVar, overload
 
 from twisted.python.filepath import FilePath
 
-try:
-    _open  # type: ignore[has-type, used-before-def]
-except NameError:
-    _open = open
+_T = TypeVar("_T", bound=None)
 
 
 class DirDBM:
@@ -41,9 +37,8 @@ class DirDBM:
     flat files. It can only use strings as keys or values.
     """
 
-    def __init__(self, name):
+    def __init__(self, name: bytes) -> None:
         """
-        @type name: str
         @param name: Base path to use for the directory storage.
         """
         self.dname = os.path.abspath(name)
@@ -69,46 +64,46 @@ class DirDBM:
                 else:
                     os.rename(f, old)
 
-    def _encode(self, k):
+    def _encode(self, k: bytes) -> bytes:
         """
         Encode a key so it can be used as a filename.
         """
         # NOTE: '_' is NOT in the base64 alphabet!
         return base64.encodebytes(k).replace(b"\n", b"_").replace(b"/", b"-")
 
-    def _decode(self, k):
+    def _decode(self, k: bytes) -> bytes:
         """
         Decode a filename to get the key.
         """
         return base64.decodebytes(k.replace(b"_", b"\n").replace(b"-", b"/"))
 
-    def _readFile(self, path):
+    def _readFile(self, path: FilePath[AnyStr]) -> bytes:
         """
         Read in the contents of a file.
 
         Override in subclasses to e.g. provide transparently encrypted dirdbm.
         """
-        with _open(path.path, "rb") as f:
+        with path.open() as f:
             s = f.read()
         return s
 
-    def _writeFile(self, path, data):
+    def _writeFile(self, path: FilePath[AnyStr], data: bytes) -> None:
         """
         Write data to a file.
 
         Override in subclasses to e.g. provide transparently encrypted dirdbm.
         """
-        with _open(path.path, "wb") as f:
+        with path.open("w") as f:
             f.write(data)
             f.flush()
 
-    def __len__(self):
+    def __len__(self) -> int:
         """
         @return: The number of key/value pairs in this Shelf
         """
         return len(self._dnamePath.listdir())
 
-    def __setitem__(self, k, v):
+    def __setitem__(self, k: bytes, v: bytes) -> None:
         """
         C{dirdbm[k] = v}
         Create or modify a textfile in this directory
@@ -142,15 +137,14 @@ class DirDBM:
                 old.remove()
             new.moveTo(old)
 
-    def __getitem__(self, k):
+    def __getitem__(self, k: bytes) -> bytes:
         """
-        C{dirdbm[k]}
-        Get the contents of a file in this directory as a string.
+        C{dirdbm[k]} Get the contents of a file in this directory as a string.
 
-        @type k: bytes
         @param k: key to lookup
 
         @return: The value associated with C{k}
+
         @raise KeyError: Raised when there is no such key
         """
         if not type(k) == bytes:
@@ -161,7 +155,7 @@ class DirDBM:
         except OSError:
             raise KeyError(k)
 
-    def __delitem__(self, k):
+    def __delitem__(self, k: bytes) -> None:
         """
         C{del dirdbm[foo]}
         Delete a file in this directory.
@@ -179,13 +173,13 @@ class DirDBM:
         except OSError:
             raise KeyError(self._decode(k))
 
-    def keys(self):
+    def keys(self) -> Iterable[bytes]:
         """
         @return: a L{list} of filenames (keys).
         """
         return list(map(self._decode, self._dnamePath.asBytesMode().listdir()))
 
-    def values(self):
+    def values(self) -> Iterable[bytes]:
         """
         @return: a L{list} of file-contents (values).
         """
@@ -195,7 +189,7 @@ class DirDBM:
             vals.append(self[key])
         return vals
 
-    def items(self):
+    def items(self) -> Iterable[tuple[bytes, bytes]]:
         """
         @return: a L{list} of 2-tuples containing key/value pairs.
         """
@@ -205,7 +199,7 @@ class DirDBM:
             items.append((key, self[key]))
         return items
 
-    def has_key(self, key):
+    def has_key(self, key: bytes) -> bool:
         """
         @type key: bytes
         @param key: The key to test
@@ -218,7 +212,7 @@ class DirDBM:
         key = self._encode(key)
         return self._dnamePath.child(key).isfile()
 
-    def setdefault(self, key, value):
+    def setdefault(self, key: bytes, value: bytes) -> bytes:
         """
         @type key: bytes
         @param key: The key to lookup
@@ -231,71 +225,76 @@ class DirDBM:
             return value
         return self[key]
 
-    def get(self, key, default=None):
+    @overload
+    def get(self, key: bytes) -> bytes:
+        ...
+
+    @overload
+    def get(self, key: bytes, default: _T) -> bytes | _T:
+        ...
+
+    def get(self, key: bytes, default: _T | None = None) -> bytes | _T | None:
         """
-        @type key: bytes
         @param key: The key to lookup
 
         @param default: The value to return if the given key does not exist
 
         @return: The value associated with C{key} or C{default} if not
-        L{DirDBM.has_key(key)}
+            L{DirDBM.has_key(key)}
         """
         if key in self:
             return self[key]
         else:
             return default
 
-    def __contains__(self, key):
+    def __contains__(self, key: bytes) -> bool:
         """
         @see: L{DirDBM.has_key}
         """
         return self.has_key(key)
 
-    def update(self, dict):
+    def update(self, other: Mapping[bytes, bytes]) -> None:
         """
         Add all the key/value pairs in L{dict} to this dirdbm.  Any conflicting
         keys will be overwritten with the values from L{dict}.
 
-        @type dict: mapping
         @param dict: A mapping of key/value pairs to add to this dirdbm.
         """
-        for key, val in dict.items():
+        for key, val in other.items():
             self[key] = val
 
-    def copyTo(self, path):
+    def copyTo(self, path: bytes) -> DirDBM:
         """
         Copy the contents of this dirdbm to the dirdbm at C{path}.
 
-        @type path: L{str}
         @param path: The path of the dirdbm to copy to.  If a dirdbm
         exists at the destination path, it is cleared first.
 
         @rtype: C{DirDBM}
         @return: The dirdbm this dirdbm was copied to.
         """
-        path = FilePath(path)
-        assert path != self._dnamePath
+        fpath = FilePath(path)
+        assert fpath != self._dnamePath
 
-        d = self.__class__(path.path)
+        d = self.__class__(fpath.path)
         d.clear()
         for k in self.keys():
             d[k] = self[k]
         return d
 
-    def clear(self):
+    def clear(self) -> None:
         """
         Delete all key/value pairs in this dirdbm.
         """
         for k in self.keys():
             del self[k]
 
-    def close(self):
+    def close(self) -> None:
         """
         Close this dbm: no-op, for dbm-style interface compliance.
         """
 
-    def getModificationTime(self, key):
+    def getModificationTime(self, key: bytes) -> float:
         """
         Returns modification time of an entry.
 


Test patch:
diff --git a/src/twisted/mail/test/test_mail.py b/src/twisted/mail/test/test_mail.py
index 0751f7b40b..a5eebd3ad1 100644
--- a/src/twisted/mail/test/test_mail.py
+++ b/src/twisted/mail/test/test_mail.py
@@ -4,6 +4,7 @@
 """
 Tests for large portions of L{twisted.mail}.
 """
+from __future__ import annotations
 
 import email.message
 import email.parser
@@ -19,6 +20,7 @@ import tempfile
 import textwrap
 import time
 from hashlib import md5
+from typing import Any, NoReturn
 from unittest import skipIf
 
 from zope.interface import Interface, implementer
@@ -48,6 +50,7 @@ from twisted.internet.testing import (
     StringTransport,
 )
 from twisted.mail import pop3, smtp
+from twisted.mail.maildir import MaildirMailbox
 from twisted.mail.relaymanager import _AttemptManager
 from twisted.names import dns
 from twisted.names.dns import Record_CNAME, Record_MX, RRHeader
@@ -456,23 +459,23 @@ class _AppendTestMixin:
     for serially appending multiple messages to a mailbox.
     """
 
-    def _appendMessages(self, mbox, messages):
+    def _appendMessages(
+        self, mbox: twisted.mail.maildir.MaildirMailbox, messages: list[bytes]
+    ) -> Deferred[list[None]]:
         """
         Deliver the given messages one at a time.  Delivery is serialized to
         guarantee a predictable order in the mailbox (overlapped message deliver
         makes no guarantees about which message which appear first).
         """
-        results = []
 
-        def append():
+        async def append() -> list[None]:
+            results: list[None] = []
             for m in messages:
-                d = mbox.appendMessage(m)
-                d.addCallback(results.append)
-                yield d
+                await mbox.appendMessage(m)
+                results.append(None)
+            return results
 
-        d = task.cooperate(append()).whenDone()
-        d.addCallback(lambda ignored: results)
-        return d
+        return Deferred.fromCoroutine(append())
 
 
 @skipIf(platformType != "posix", "twisted.mail only works on posix")
@@ -481,12 +484,12 @@ class MaildirAppendStringTests(TestCase, _AppendTestMixin):
     Tests for L{MaildirMailbox.appendMessage} when invoked with a C{str}.
     """
 
-    def setUp(self):
-        self.d = self.mktemp()
+    def setUp(self) -> None:
+        self.d = os.fsencode(self.mktemp())
         mail.maildir.initializeMaildir(self.d)
 
-    def _append(self, ignored, mbox):
-        d = mbox.appendMessage("TEST")
+    def _append(self, ignored: object, mbox: MaildirMailbox) -> Deferred[None]:
+        d = mbox.appendMessage(b"TEST")
         return self.assertFailure(d, Exception)
 
     def _setState(self, ignored, mbox, rename=None, write=None, open=None):
@@ -524,8 +527,7 @@ class MaildirAppendStringTests(TestCase, _AppendTestMixin):
             )
             mbox.AppendFactory._openstate = open
 
-    @skipIf(sys.version_info >= (3,), "not ported to Python 3")
-    def test_append(self):
+    def test_append(self) -> Any:
         """
         L{MaildirMailbox.appendMessage} returns a L{Deferred} which fires when
         the message has been added to the end of the mailbox.
@@ -533,11 +535,30 @@ class MaildirAppendStringTests(TestCase, _AppendTestMixin):
         mbox = mail.maildir.MaildirMailbox(self.d)
         mbox.AppendFactory = FailingMaildirMailboxAppendMessageTask
 
-        d = self._appendMessages(mbox, ["X" * i for i in range(1, 11)])
+        d = self._appendMessages(mbox, [b"X" * i for i in range(1, 11)])
         d.addCallback(self.assertEqual, [None] * 10)
         d.addCallback(self._cbTestAppend, mbox)
         return d
 
+    def test_reopenMailbox(self) -> Any:
+        """
+        Appending a message, then re-opening the mailbox by creating a new
+        MaildirMailbox, should result in seeing the message as loadable.
+        """
+
+        async def go() -> None:
+            mbox1 = mail.maildir.MaildirMailbox(self.d)
+            sample = b"xyzzy"
+            await mbox1.appendMessage(sample)
+            mbox2 = mail.maildir.MaildirMailbox(self.d)
+            listed = mbox2.listMessages()
+            self.assertEqual(len(listed), 1)
+            [messageSize] = listed
+            self.assertEqual(messageSize, len(sample))
+            self.assertEqual(mbox2.getMessage(0).read(), sample.decode())
+
+        return Deferred.fromCoroutine(go())
+
     def _cbTestAppend(self, ignored, mbox):
         """
         Check that the mailbox has the expected number (ten) of messages in it,
@@ -708,7 +729,7 @@ class MaildirDirdbmDomainTests(TestCase):
     Tests for L{MaildirDirdbmDomain}.
     """
 
-    def setUp(self):
+    def setUp(self) -> None:
         """
         Create a temporary L{MaildirDirdbmDomain} and parent
         L{MailService} before running each test.
@@ -795,8 +816,7 @@ class MaildirDirdbmDomainTests(TestCase):
         creds = cred.credentials.UsernamePassword("user", "password")
         self.assertEqual(database.requestAvatarId(creds), "user")
 
-    @skipIf(sys.version_info >= (3,), "not ported to Python 3")
-    def test_userDirectory(self):
+    def test_userDirectory(self) -> None:
         """
         L{MaildirDirdbmDomain.userDirectory} is supplied with a user name
         and returns the path to that user's maildir subdirectory.
@@ -804,17 +824,18 @@ class MaildirDirdbmDomainTests(TestCase):
         non-existent user returns the 'postmaster' directory if there
         is a postmaster or returns L{None} if there is no postmaster.
         """
-        self.D.addUser("user", "password")
+        self.D.addUser(b"user", b"password")
         self.assertEqual(
-            self.D.userDirectory("user"), os.path.join(self.D.root, "user")
+            self.D.userDirectory(b"user"), os.path.join(self.D.root, b"user")
         )
 
         self.D.postmaster = False
-        self.assertIdentical(self.D.userDirectory("nouser"), None)
+        self.assertIdentical(self.D.userDirectory(b"nouser"), None)
 
         self.D.postmaster = True
         self.assertEqual(
-            self.D.userDirectory("nouser"), os.path.join(self.D.root, "postmaster")
+            self.D.userDirectory(b"nouser"),
+            os.path.join(self.D.root, b"postmaster"),
         )
 
 
@@ -853,6 +874,14 @@ class StubAliasableDomain:
         """
         self.aliasGroup = aliases
 
+    def requestAvatar(
+        self, avatarId: bytes | tuple[()], mind: object, *interfaces: type[Interface]
+    ) -> NoReturn:
+        """
+        Stub domains cannot authenticate users.
+        """
+        raise NotImplementedError()
+
 
 @skipIf(platformType != "posix", "twisted.mail only works on posix")
 class ServiceDomainTests(TestCase):
@@ -935,20 +964,16 @@ class ServiceDomainTests(TestCase):
 
 @skipIf(platformType != "posix", "twisted.mail only works on posix")
 class VirtualPOP3Tests(TestCase):
-    def setUp(self):
+    def setUp(self) -> None:
         self.tmpdir = self.mktemp()
         self.S = mail.mail.MailService()
         self.D = mail.maildir.MaildirDirdbmDomain(self.S, self.tmpdir)
         self.D.addUser(b"user", b"password")
-        self.S.addDomain("test.domain", self.D)
-
-        portal = cred.portal.Portal(self.D)
-        map(portal.registerChecker, self.D.getCredentialsCheckers())
-        self.S.portals[""] = self.S.portals["test.domain"] = portal
-
+        self.S.addDomain(b"test.domain", self.D)
+        self.S.addDomain(b"", self.D)
         self.P = mail.protocols.VirtualPOP3()
         self.P.service = self.S
-        self.P.magic = "<unit test magic>"
+        self.P.magic = b"<unit test magic>"
 
     def tearDown(self):
         shutil.rmtree(self.tmpdir)
@@ -980,9 +1005,8 @@ class VirtualPOP3Tests(TestCase):
             self.P.authenticateUserAPOP("user", resp), cred.error.UnauthorizedLogin
         )
 
-    @skipIf(sys.version_info >= (3,), "not ported to Python 3")
-    def testAuthenticatePASS(self):
-        return self.P.authenticateUserPASS("user", "password").addCallback(
+    def testAuthenticatePASS(self) -> Deferred[object]:
+        return self.P.authenticateUserPASS(b"user", b"password").addCallback(
             self._cbAuthenticatePASS
         )
 
@@ -992,17 +1016,15 @@ class VirtualPOP3Tests(TestCase):
         self.assertTrue(pop3.IMailbox.providedBy(result[1]))
         result[2]()
 
-    @skipIf(sys.version_info >= (3,), "not ported to Python 3")
-    def testAuthenticateBadUserPASS(self):
+    def testAuthenticateBadUserPASS(self) -> Any:
         return self.assertFailure(
-            self.P.authenticateUserPASS("resu", "password"),
+            self.P.authenticateUserPASS(b"resu", b"password"),
             cred.error.UnauthorizedLogin,
         )
 
-    @skipIf(sys.version_info >= (3,), "not ported to Python 3")
-    def testAuthenticateBadPasswordPASS(self):
+    def testAuthenticateBadPasswordPASS(self) -> Any:
         return self.assertFailure(
-            self.P.authenticateUserPASS("user", "wrong password"),
+            self.P.authenticateUserPASS(b"user", b"wrong password"),
             cred.error.UnauthorizedLogin,
         )
 
diff --git a/src/twisted/test/test_dirdbm.py b/src/twisted/test/test_dirdbm.py
index 756c612877..7dfc0e1a57 100644
--- a/src/twisted/test/test_dirdbm.py
+++ b/src/twisted/test/test_dirdbm.py
@@ -30,8 +30,16 @@ class DirDbmTests(unittest.TestCase):
         self.dbm[k] = b"a"
         self.assertEqual(self.dbm[k], b"a")
 
+    def test_update(self) -> None:
+        """
+        L{DirDBM.update} updates the DBM with values from a bytes:bytes
+        mapping.
+        """
+        self.dbm.update({b"a": b"b", b"c": b"d"})
+        self.assertEqual(set(self.dbm.items()), {(b"a", b"b"), (b"c", b"d")})
+
     def test_rebuildInteraction(self) -> None:
-        s = dirdbm.Shelf("dirdbm.rebuild.test")
+        s = dirdbm.Shelf(b"dirdbm.rebuild.test")
         s[b"key"] = b"value"
         rebuild.rebuild(dirdbm)