changeset 4300:7ded09452875

plugin XEP-0077, XEP-0100: Adapt to component, and modernize: - Plugin XEP-0077 can now be used with component, allowing to register methods to return registration form, and to (un)register. - Plugin XEP-0077 now advertises its feature in disco. - Plugin XEP-0100 has been modernized a bit: it is one of the older plugin in Libervia, and it has now some type hints, models and async methods. - Plugin XEP-0100's bridge method `gateways_find` now returns a serialised dict with relevant data. Former XMLUI version has been moved to `gateways_find_xmlui`. rel 449
author Goffi <goffi@goffi.org>
date Fri, 06 Sep 2024 18:01:31 +0200
parents d2deddd6df44
children 9deb3ddb2921
files libervia/backend/plugins/plugin_xep_0077.py libervia/backend/plugins/plugin_xep_0100.py
diffstat 2 files changed, 467 insertions(+), 166 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/backend/plugins/plugin_xep_0077.py	Fri Sep 06 17:45:46 2024 +0200
+++ b/libervia/backend/plugins/plugin_xep_0077.py	Fri Sep 06 18:01:31 2024 +0200
@@ -17,34 +17,56 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from twisted.words.protocols.jabber import jid, xmlstream, client, error as jabber_error
+from typing import Awaitable, Callable, NamedTuple, cast
+
 from twisted.internet import defer, reactor, ssl
-from wokkel import data_form
+from twisted.internet.interfaces import IReactorCore
+from twisted.python.failure import Failure
+from twisted.words.protocols.jabber import (
+    client,
+    error as jabber_error,
+    jid,
+    xmlstream as xmlstream_mod,
+)
+from twisted.words.xish import domish
+from wokkel import data_form, disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
 from libervia.backend.core.i18n import _
-from libervia.backend.core.constants import Const as C
-from libervia.backend.core import exceptions
 from libervia.backend.core.log import getLogger
 from libervia.backend.core.xmpp import SatXMPPEntity
 from libervia.backend.tools import xml_tools
+from libervia.backend.tools.utils import as_deferred, ensure_deferred
 
 log = getLogger(__name__)
 
-NS_REG = "jabber:iq:register"
+NS_IQ_REGISTER = "jabber:iq:register"
+IQ_REGISTER_REQUEST = f'{C.IQ_GET}/query[@xmlns="{NS_IQ_REGISTER}"]'
+IQ_SUBMIT_REQUEST = f'{C.IQ_SET}/query[@xmlns="{NS_IQ_REGISTER}"]'
 
 PLUGIN_INFO = {
-    C.PI_NAME: "XEP 0077 Plugin",
+    C.PI_NAME: "In-Band Registration",
     C.PI_IMPORT_NAME: "XEP-0077",
     C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
     C.PI_PROTOCOLS: ["XEP-0077"],
     C.PI_DEPENDENCIES: [],
     C.PI_MAIN: "XEP_0077",
-    C.PI_DESCRIPTION: _("""Implementation of in-band registration"""),
+    C.PI_DESCRIPTION: _("""Implementation of in-band registration."""),
+    C.PI_HANDLER: C.BOOL_TRUE,
 }
 
 # FIXME: this implementation is incomplete
 
 
-class RegisteringAuthenticator(xmlstream.ConnectAuthenticator):
+class RegistrationHandlers(NamedTuple):
+    form_handler: Callable
+    submit_handler: Callable
+
+
+class RegisteringAuthenticator(xmlstream_mod.ConnectAuthenticator):
     # FIXME: request IQ is not send to check available fields,
     #        while XEP recommand to use it
     # FIXME: doesn't handle data form or oob
@@ -52,28 +74,28 @@
 
     def __init__(self, jid_, password, email=None, check_certificate=True):
         log.debug(_("Registration asked for {jid}").format(jid=jid_))
-        xmlstream.ConnectAuthenticator.__init__(self, jid_.host)
+        xmlstream_mod.ConnectAuthenticator.__init__(self, jid_.host)
         self.jid = jid_
         self.password = password
         self.email = email
         self.check_certificate = check_certificate
         self.registered = defer.Deferred()
 
-    def associateWithStream(self, xs):
-        xmlstream.ConnectAuthenticator.associateWithStream(self, xs)
-        xs.addObserver(xmlstream.STREAM_AUTHD_EVENT, self.register)
+    def associateWithStream(self, xmlstream):
+        xmlstream_mod.ConnectAuthenticator.associateWithStream(self, xmlstream)
+        xmlstream.addObserver(xmlstream_mod.STREAM_AUTHD_EVENT, self.register)
 
-        xs.initializers = [client.CheckVersionInitializer(xs)]
+        xmlstream.initializers = [client.CheckVersionInitializer(xmlstream)]
         if self.check_certificate:
             tls_required, configurationForTLS = True, None
         else:
             tls_required = False
             configurationForTLS = ssl.CertificateOptions(trustRoot=None)
-        tls_init = xmlstream.TLSInitiatingInitializer(
-            xs, required=tls_required, configurationForTLS=configurationForTLS
+        tls_init = xmlstream_mod.TLSInitiatingInitializer(
+            xmlstream, required=tls_required, configurationForTLS=configurationForTLS
         )
 
-        xs.initializers.append(tls_init)
+        xmlstream.initializers.append(tls_init)
 
     def register(self, xmlstream):
         log.debug(
@@ -93,21 +115,23 @@
 
     def registration_cb(self, answer):
         log.debug(_("Registration answer: {}").format(answer.toXml()))
+        assert self.xmlstream is not None
         self.xmlstream.sendFooter()
 
     def registration_eb(self, failure_):
         log.info(_("Registration failure: {}").format(str(failure_.value)))
+        assert self.xmlstream is not None
         self.xmlstream.sendFooter()
         raise failure_
 
 
-class ServerRegister(xmlstream.XmlStreamFactory):
+class ServerRegister(xmlstream_mod.XmlStreamFactory):
 
     def __init__(self, *args, **kwargs):
-        xmlstream.XmlStreamFactory.__init__(self, *args, **kwargs)
-        self.addBootstrap(xmlstream.STREAM_END_EVENT, self._disconnected)
+        xmlstream_mod.XmlStreamFactory.__init__(self, *args, **kwargs)
+        self.addBootstrap(xmlstream_mod.STREAM_END_EVENT, self._disconnected)
 
-    def clientConnectionLost(self, connector, reason):
+    def clientConnectionLost(self, connector, unused_reason):
         connector.disconnect()
 
     def _disconnected(self, reason):
@@ -121,10 +145,12 @@
             self.authenticator.registered.errback(err)
 
 
-class XEP_0077(object):
+class XEP_0077:
+
     def __init__(self, host):
-        log.info(_("Plugin XEP_0077 initialization"))
+        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
         self.host = host
+        host.register_namespace("iq-register", NS_IQ_REGISTER)
         host.bridge.add_method(
             "in_band_register",
             ".plugin",
@@ -157,10 +183,162 @@
             method=self._change_password,
             async_=True,
         )
+        self.handlers = set()
+
+    def get_handler(self, client: SatXMPPEntity) -> xmlstream_mod.XMPPHandler:
+        return XEP_0077_handler(self)
+
+    def register_handler(
+        self,
+        form_handler: Callable[
+            [SatXMPPEntity, domish.Element],
+            Awaitable[tuple[bool, data_form.Form] | None]
+            | defer.Deferred[tuple[bool, data_form.Form] | None]
+            | tuple[bool, data_form.Form]
+            | None,
+        ],
+        submit_handler: Callable[
+            [SatXMPPEntity, domish.Element, data_form.Form | None],
+            Awaitable[bool | None] | defer.Deferred[bool | None] | bool | None,
+        ],
+    ) -> None:
+        """
+        Register a new handler.
+
+        Mostly useful for component which handle In-Band Registration.
+        @param form_handler: method to call on registration request to get a data form.
+            May be async.
+            The handler must return a data_form.Form instance if it can handle the
+            request, otherwise other handlers will be tried until one returns a data form.
+        @param submit_handler: method to call on registration request to submit the
+            handler.
+            In case of "unregister" request, None will be used instead of a
+            data_form.Form.
+        """
+        self.handlers.add(RegistrationHandlers(form_handler, submit_handler))
+
+    def _on_register_request(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
+        defer.ensureDeferred(self._on_request(iq_elt, client, True))
+
+    def _on_submit_request(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
+        defer.ensureDeferred(self._on_request(iq_elt, client, False))
+
+    async def _on_request(
+        self, iq_elt: domish.Element, client: SatXMPPEntity, is_register: bool
+    ) -> None:
+        """Handle a register or submit request.
+
+        @param iq_elt: The IQ element of the request.
+        @param client: Client session.
+        @param is_register: Whether this is a register request (True) or a submit request
+            (False).
+        """
+        iq_elt.handled = True
+
+        # Submit request must have a form with submitted values.
+        if is_register:
+            handler_type = "register"
+            submit_form = None
+        else:
+            handler_type = "submit"
+            remove_elt = next(iq_elt.query.elements(NS_IQ_REGISTER, "remove"), None)
+            if remove_elt is not None:
+                # This is a unregister request.
+                submit_form = None
+            else:
+                submit_form = data_form.findForm(iq_elt.query, NS_IQ_REGISTER)
+                if submit_form is None:
+                    log.warning(f"Data form not found, invalid request: {iq_elt.toXml()}")
+                    client.send(
+                        jabber_error.StanzaError(
+                            "bad-request", text="No data form found."
+                        ).toResponse(iq_elt)
+                    )
+                    return
+
+        # We look and run relevant handler.
+        for handlers in self.handlers:
+            if is_register:
+                handler = handlers.form_handler
+                handler_call = as_deferred(handler, client, iq_elt)
+            else:
+                handler = handlers.submit_handler
+                handler_call = as_deferred(handler, client, iq_elt, submit_form)
+            try:
+                callback_ret = await handler_call
+            except jabber_error.StanzaError as e:
+                iq_error_elt = e.toResponse(iq_elt)
+                client.send(iq_error_elt)
+                return
+            except exceptions.PasswordError as e:
+                log.warning("Invalid login or password while registering to service.")
+                iq_error_elt = jabber_error.StanzaError(
+                    "forbidden", text=str(e)
+                ).toResponse(iq_elt)
+                client.send(iq_error_elt)
+                return
+            except Exception:
+                log.exception(
+                    f"Error while handling {handler_type} request with {handler}, "
+                    "ignoring it."
+                )
+                continue
+            if callback_ret is not None:
+                if is_register:
+                    try:
+                        registered, registration_form = callback_ret
+                        assert isinstance(registered, bool)
+                        assert isinstance(registration_form, data_form.Form)
+                    except (TypeError, ValueError, AssertionError) as e:
+                        log.warning(
+                            f"Invalid return value from {handler}, ignoring it: {e}"
+                        )
+                        continue
+                    # We need to be sure to have the right namespace for the form.
+                    registration_form.formNamespace = NS_IQ_REGISTER
+                    iq_result_elt = xmlstream_mod.toResponse(iq_elt, "result")
+                    query_elt = iq_result_elt.addElement((NS_IQ_REGISTER, "query"))
+
+                    if registered:
+                        # The requestor is already registered, we indicate it.
+                        query_elt.addElement("registered")
+
+                    query_elt.addChild(registration_form.toElement())
+                    client.send(iq_result_elt)
+                else:
+                    if callback_ret is True:
+                        if submit_form is None:
+                            log.info(f"User {iq_elt['from']} successfully unregistered.")
+                        else:
+                            log.info(f"User {iq_elt['from']} successfully registered.")
+                    else:
+                        log.error(
+                            f"Unexpected return value from {handler}, was expecting "
+                            '"True".'
+                        )
+                        client.send(
+                            jabber_error.StanzaError(
+                                "internal-server-error", text="Error in request handler."
+                            ).toResponse(iq_elt)
+                        )
+                        return
+                    iq_result_elt = xmlstream_mod.toResponse(iq_elt, "result")
+                    client.send(iq_result_elt)
+                break
+        else:
+            log.warning(
+                f"No handler found for in-band registration {handler_type} request: "
+                f"{iq_elt.toXml()}."
+            )
+            iq_error_elt = jabber_error.StanzaError("service-unavailable").toResponse(
+                iq_elt
+            )
+            client.send(iq_error_elt)
+            return
 
     @staticmethod
-    def build_register_iq(xmlstream_, jid_, password, email=None):
-        iq_elt = xmlstream.IQ(xmlstream_, "set")
+    def build_register_iq(xmlstream, jid_, password, email=None):
+        iq_elt = xmlstream_mod.IQ(xmlstream, "set")
         iq_elt["to"] = jid_.host
         query_elt = iq_elt.addElement(("jabber:iq:register", "query"))
         username_elt = query_elt.addElement("username")
@@ -172,82 +350,116 @@
             email_elt.addContent(email)
         return iq_elt
 
-    def _reg_cb(self, answer, client, post_treat_cb):
-        """Called after the first get IQ"""
-        try:
-            query_elt = next(answer.elements(NS_REG, "query"))
-        except StopIteration:
-            raise exceptions.DataError("Can't find expected query element")
-
-        try:
-            x_elem = next(query_elt.elements(data_form.NS_X_DATA, "x"))
-        except StopIteration:
-            # XXX: it seems we have an old service which doesn't manage data forms
-            log.warning(_("Can't find data form"))
-            raise exceptions.DataError(
-                _("This gateway can't be managed by SàT, sorry :(")
-            )
-
-        def submit_form(data, profile):
-            form_elt = xml_tools.xmlui_result_to_elt(data)
-
-            iq_elt = client.IQ()
-            iq_elt["id"] = answer["id"]
-            iq_elt["to"] = answer["from"]
-            query_elt = iq_elt.addElement("query", NS_REG)
-            query_elt.addChild(form_elt)
-            d = iq_elt.send()
-            d.addCallback(self._reg_success, client, post_treat_cb)
-            d.addErrback(self._reg_failure, client)
-            return d
+    def _in_band_register(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(self.in_band_register(client, jid.JID(to_jid_s)))
 
-        form = data_form.Form.fromElement(x_elem)
-        submit_reg_id = self.host.register_callback(
-            submit_form, with_data=True, one_shot=True
-        )
-        return xml_tools.data_form_2_xmlui(form, submit_reg_id)
-
-    def _reg_eb(self, failure, client):
-        """Called when something is wrong with registration"""
-        log.info(_("Registration failure: %s") % str(failure.value))
-        raise failure
+    async def in_band_register(
+        self,
+        client: SatXMPPEntity,
+        to_jid: jid.JID,
+        post_treat_cb: Callable | None = None,
+    ) -> xml_tools.XMLUI:
+        """Register to a service.
 
-    def _reg_success(self, answer, client, post_treat_cb):
-        log.debug(_("registration answer: %s") % answer.toXml())
-        if post_treat_cb is not None:
-            post_treat_cb(jid.JID(answer["from"]), client.profile)
-        return {}
+        Send an IQ request to register with the given service and return the registration
+        form as XML UI.
 
-    def _reg_failure(self, failure, client):
-        log.info(_("Registration failure: %s") % str(failure.value))
-        if failure.value.condition == "conflict":
-            raise exceptions.ConflictError(
-                _("Username already exists, please choose an other one")
-            )
-        raise failure
-
-    def _in_band_register(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
-        return self.in_band_register, jid.JID(to_jid_s, profile_key)
-
-    def in_band_register(self, to_jid, post_treat_cb=None, profile_key=C.PROF_KEY_NONE):
-        """register to a service
-
-        @param to_jid(jid.JID): jid of the service to register to
+        @param client: client session.
+        @param to_jid: The JID of the service to register to.
+        @param post_treat_cb: A callback function to handle the registration result, if
+            provided.
+        @return: The registration form as XML UI.
         """
         # FIXME: this post_treat_cb arguments seems wrong, check it
-        client = self.host.get_client(profile_key)
         log.debug(_("Asking registration for {}").format(to_jid.full()))
         reg_request = client.IQ("get")
         reg_request["from"] = client.jid.full()
         reg_request["to"] = to_jid.full()
-        reg_request.addElement("query", NS_REG)
-        d = reg_request.send(to_jid.full()).addCallbacks(
-            self._reg_cb,
-            self._reg_eb,
-            callbackArgs=[client, post_treat_cb],
-            errbackArgs=[client],
-        )
-        return d
+        reg_request.addElement("query", NS_IQ_REGISTER)
+        try:
+            iq_result_elt = await reg_request.send(to_jid.full())
+        except Exception as e:
+            log.warning(_("Registration failure: {}").format(e))
+            raise e
+        else:
+            try:
+                query_elt = next(iq_result_elt.elements(NS_IQ_REGISTER, "query"))
+            except StopIteration:
+                raise exceptions.DataError(
+                    f"Can't find expected query element: {iq_result_elt.toXml()}"
+                )
+
+            try:
+                x_elem = next(query_elt.elements(data_form.NS_X_DATA, "x"))
+            except StopIteration:
+                # XXX: it seems we have an old service which doesn't manage data forms
+                log.warning("Can't find data form: {iq_result_elt.toXml()}")
+                raise exceptions.DataError(
+                    _(
+                        "This gateway is outdated and can't be managed by Libervia, "
+                        "sorry :("
+                    )
+                )
+
+            form = data_form.Form.fromElement(x_elem)
+            xml_ui = xml_tools.data_form_2_xmlui(form, "")
+            d = xml_tools.deferred_ui(self.host, xml_ui)
+            d.addCallback(
+                self._on_xml_ui_cb,
+                client,
+                iq_result_elt["id"],
+                iq_result_elt["from"],
+                post_treat_cb,
+            )
+            d.addErrback(self._on_xml_ui_eb)
+            d.addTimeout(600, cast(IReactorCore, reactor))
+            return xml_ui
+
+    @ensure_deferred
+    async def _on_xml_ui_cb(
+        self,
+        data: dict,
+        client: SatXMPPEntity,
+        stanza_id: str,
+        to_: str,
+        post_treat_cb: Callable | None,
+    ) -> None:
+        """Handle the XML UI result of a registration form.
+
+        Process the filled registration form (from frontend) and send it back to complete
+        the registration process.
+
+        @param data: The filled registration form as a XMLUI Form result.
+        @param client: Client session.
+        @param stanza_id: The ID of the IQ stanza of the registration process.
+        @param to_: The JID of the service that sent the registration form.
+        @param post_treat_cb: A callback function to handle the registration result, if
+            provided.
+        """
+        form_elt = xml_tools.xmlui_result_to_elt(data, NS_IQ_REGISTER)
+
+        iq_elt = client.IQ()
+        iq_elt["id"] = stanza_id
+        iq_elt["to"] = to_
+        query_elt = iq_elt.addElement("query", NS_IQ_REGISTER)
+        query_elt.addChild(form_elt)
+        try:
+            answer = await iq_elt.send()
+            log.debug(_("registration answer: %s") % answer.toXml())
+            if post_treat_cb is not None:
+                post_treat_cb(jid.JID(answer["from"]), client.profile)
+        except jabber_error.StanzaError as e:
+            log.info(_("Registration failure: {}").format(e))
+            if e.condition == "conflict":
+                raise exceptions.ConflictError(
+                    _("Username already exists, please choose another one.")
+                )
+            raise e
+
+    def _on_xml_ui_eb(self, failure_: Failure) -> None:
+        """Handle error during handling of registration form by frontend."""
+        log.warning(f"Error while handling registration form to frontend: {failure_}")
 
     def _register_new_account(self, jid_, password, email, host, port):
         kwargs = {}
@@ -260,15 +472,20 @@
         return self.register_new_account(jid.JID(jid_), password, **kwargs)
 
     def register_new_account(
-        self, jid_, password, email=None, host=None, port=C.XMPP_C2S_PORT
+        self,
+        jid_: jid.JID,
+        password: str,
+        email: str | None = None,
+        host: str | None = None,
+        port: int = C.XMPP_C2S_PORT,
     ):
-        """register a new account on a XMPP server
+        """Register a new account on a XMPP server.
 
-        @param jid_(jid.JID): request jid to register
-        @param password(unicode): password of the account
-        @param email(unicode): email of the account
-        @param host(None, unicode): host of the server to register to
-        @param port(int): port of the server to register to
+        @param jid_: request jid to register
+        @param password: password of the account
+        @param email: email of the account
+        @param host: host of the server to register to
+        @param port: port of the server to register to
         """
         if host is None:
             host = self.host.memory.config_get("", "xmpp_domain", "127.0.0.1")
@@ -300,7 +517,7 @@
         return self.unregister(client, jid.JID(to_jid_s))
 
     def unregister(self, client: SatXMPPEntity, to_jid: jid.JID) -> defer.Deferred:
-        """remove registration from a server/service
+        """Remove registration from a server/service.
 
         BEWARE! if you remove registration from profile own server, this will
         DELETE THE XMPP ACCOUNT WITHOUT WARNING
@@ -310,9 +527,36 @@
         iq_elt = client.IQ()
         if to_jid is not None:
             iq_elt["to"] = to_jid.full()
-        query_elt = iq_elt.addElement((NS_REG, "query"))
+        query_elt = iq_elt.addElement((NS_IQ_REGISTER, "query"))
         query_elt.addElement("remove")
         d = iq_elt.send()
         if not to_jid or to_jid == jid.JID(client.jid.host):
             d.addCallback(lambda __: client.entity_disconnect())
         return d
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0077_handler(xmlstream_mod.XMPPHandler):
+
+    def __init__(self, plugin_parent: XEP_0077) -> None:
+        self.plugin_parent = plugin_parent
+
+    def connectionInitialized(self):
+        client = cast(SatXMPPEntity, self.parent)
+        if client.is_component:
+            self.xmlstream.addObserver(
+                IQ_REGISTER_REQUEST,
+                self.plugin_parent._on_register_request,
+                client=client,
+            )
+            self.xmlstream.addObserver(
+                IQ_SUBMIT_REQUEST,
+                self.plugin_parent._on_submit_request,
+                client=client,
+            )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_IQ_REGISTER)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- a/libervia/backend/plugins/plugin_xep_0100.py	Fri Sep 06 17:45:46 2024 +0200
+++ b/libervia/backend/plugins/plugin_xep_0100.py	Fri Sep 06 18:01:31 2024 +0200
@@ -17,15 +17,23 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from libervia.backend.core.i18n import _, D_
-from libervia.backend.core.constants import Const as C
+from typing import cast
+from pydantic import BaseModel, Field
+from twisted.internet import defer, reactor
+from twisted.words.protocols.jabber import jid
+from wokkel import disco
+
 from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.models.core import DiscoIdentity
+from libervia.backend.models.types import StrictJIDType
 from libervia.backend.tools import xml_tools
-from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
-from twisted.words.protocols.jabber import jid
-from twisted.internet import reactor, defer
+
 
 PLUGIN_INFO = {
     C.PI_NAME: "Gateways Plugin",
@@ -38,8 +46,12 @@
 }
 
 WARNING_MSG = D_(
-    """Be careful ! Gateways allow you to use an external IM (legacy IM), so you can see your contact as XMPP contacts.
-But when you do this, all your messages go throught the external legacy IM server, it is a huge privacy issue (i.e.: all your messages throught the gateway can be monitored, recorded, analysed by the external server, most of time a private company)."""
+    "Please exercise caution. Gateways facilitate the use of external instant messaging "
+    "platforms (legacy IM), enabling you to view your contacts as XMPP contacts. "
+    "However, this process routes all messages through the external legacy IM server, "
+    "raising significant privacy concerns. Specifically, it is possible for the external "
+    "server, often operated by a private company, to monitor, record, and analyze all "
+    "messages that traverse the gateway."
 )
 
 GATEWAY_TIMEOUT = 10  # time to wait before cancelling a gateway disco info, in seconds
@@ -54,9 +66,22 @@
     "gadu-gadu": D_("Gadu-Gadu"),
     "aim": D_("AOL Instant Messenger"),
     "msn": D_("Windows Live Messenger"),
+    "smtp": D_("Email"),
 }
 
 
+class GatewayData(BaseModel):
+    entity: StrictJIDType
+    identities: list[DiscoIdentity]
+
+
+class FoundGateways(BaseModel):
+    available: list[GatewayData]
+    unavailable: list[StrictJIDType] = Field(
+        description="Gateways registered but not answering."
+    )
+
+
 class XEP_0100(object):
     def __init__(self, host):
         log.info(_("Gateways plugin initialization"))
@@ -69,7 +94,14 @@
             ".plugin",
             in_sign="ss",
             out_sign="s",
-            method=self._find_gateways,
+            method=self._gateways_find,
+        )
+        host.bridge.add_method(
+            "gateways_find_xmlui",
+            ".plugin",
+            in_sign="ss",
+            out_sign="s",
+            method=self._gateways_find_xmlui,
         )
         host.bridge.add_method(
             "gateway_register",
@@ -77,6 +109,7 @@
             in_sign="ss",
             out_sign="s",
             method=self._gateway_register,
+            async_=True,
         )
         self.__menu_id = host.register_callback(self._gateways_menu, with_data=True)
         self.__selected_id = host.register_callback(
@@ -101,7 +134,7 @@
             )
         except RuntimeError:
             raise exceptions.DataError(_("Invalid JID"))
-        d = self.gateways_find(jid_, profile)
+        d = self.gateways_find_raw(jid_, profile)
         d.addCallback(self._gateways_result_2_xmlui, jid_)
         d.addCallback(lambda xmlui: {"xmlui": xmlui.toXml()})
         return d
@@ -158,7 +191,8 @@
         if category != "gateway":
             log.error(
                 _(
-                    'INTERNAL ERROR: identity category should always be "gateway" in _getTypeString, got "%s"'
+                    'INTERNAL ERROR: identity category should always be "gateway" in '
+                    '_getTypeString, got "%s"'
                 )
                 % category
             )
@@ -174,22 +208,83 @@
         self.host.presence_set(jid_, profile_key=profile)
 
     def _gateway_register(self, target_jid_s, profile_key=C.PROF_KEY_NONE):
-        d = self.gateway_register(jid.JID(target_jid_s), profile_key)
+        client = self.host.get_client(profile_key)
+        d = self.gateway_register(client, jid.JID(target_jid_s))
         d.addCallback(lambda xmlui: xmlui.toXml())
         return d
 
-    def gateway_register(self, target_jid, profile_key=C.PROF_KEY_NONE):
+    def gateway_register(
+        self, client: SatXMPPEntity, target_jid: jid.JID
+    ) -> defer.Deferred:
         """Register gateway using in-band registration, then log-in to gateway"""
-        profile = self.host.memory.get_profile_name(profile_key)
-        assert profile
-        d = self.host.plugins["XEP-0077"].in_band_register(
-            target_jid, self._registration_successful, profile
+        return defer.ensureDeferred(
+            self.host.plugins["XEP-0077"].in_band_register(
+                client, target_jid, self._registration_successful
+            )
         )
+
+    def _gateways_find(self, target_jid_s: str, profile_key: str) -> defer.Deferred[str]:
+        client = self.host.get_client(profile_key)
+        target_jid = jid.JID(target_jid_s) if target_jid_s else client.server_jid
+        d = defer.ensureDeferred(self.gateways_find(client, target_jid))
+        d.addCallback(lambda found_gateways: found_gateways.model_dump_json())
+        # The Deferred will actually return a str due to `model_dump_json`, but type
+        # checker doesn't get that.
+        d = cast(defer.Deferred[str], d)
         return d
 
-    def _infos_received(self, dl_result, items, target, client):
-        """Find disco infos about entity, to check if it is a gateway"""
+    async def gateways_find(
+        self, client: SatXMPPEntity, target: jid.JID
+    ) -> FoundGateways:
+        """Find gateways and convert FoundGateways instance."""
+        gateways_data = await self.gateways_find_raw(client, target)
+        available = []
+        unavailable = []
+        for gw_available, data in gateways_data:
+            if gw_available:
+                data = cast(tuple[jid.JID, list[tuple[tuple[str, str], str]]], data)
+                identities = []
+                for (category, type_), name in data[1]:
+                    identities.append(
+                        DiscoIdentity(name=name, category=category, type=type_)
+                    )
+                available.append(GatewayData(entity=data[0], identities=identities))
+            else:
+                disco_item = cast(disco.DiscoItem, data[1])
+                unavailable.append(disco_item.entity)
+        return FoundGateways(available=available, unavailable=unavailable)
 
+    def _gateways_find_xmlui(
+        self, target_jid_s: str, profile_key: str
+    ) -> defer.Deferred[str]:
+        target_jid = jid.JID(target_jid_s)
+        client = self.host.get_client(profile_key)
+        d = defer.ensureDeferred(self.gateways_find_raw(client, target_jid))
+        d.addCallback(self._gateways_result_2_xmlui, target_jid)
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        d = cast(defer.Deferred[str], d)
+        return d
+
+    async def gateways_find_raw(self, client: SatXMPPEntity, target: jid.JID) -> list:
+        """Find gateways in the target JID, using discovery protocol"""
+        log.debug(
+            _("find gateways (target = {target}, profile = {profile})").format(
+                target=target.full(), profile=client.profile
+            )
+        )
+        disco = await client.disco.requestItems(target)
+        if len(disco._items) == 0:
+            log.debug(_("No gateway found"))
+            return []
+
+        defers_ = []
+        for item in disco._items:
+            log.debug(_("item found: {}").format(item.entity))
+            defers_.append(client.disco.requestInfo(item.entity))
+        deferred_list = defer.DeferredList(defers_)
+        dl_result = await deferred_list
+        reactor.callLater(GATEWAY_TIMEOUT, deferred_list.cancel)
+        items = disco._items
         ret = []
         for idx, (success, result) in enumerate(dl_result):
             if not success:
@@ -209,58 +304,20 @@
                     if identity[0] == "gateway"
                 ]
                 if gateways:
-                    log.info(
-                        _("Found gateway [%(jid)s]: %(identity_name)s")
-                        % {
-                            "jid": entity.full(),
-                            "identity_name": " - ".join(
+                    log.debug(
+                        _("Found gateway [{jid}]: {identity_name}").format(
+                            jid=entity.full(),
+                            identity_name=" - ".join(
                                 [gateway[1] for gateway in gateways]
                             ),
-                        }
+                        )
                     )
                     ret.append((success, (entity, gateways)))
                 else:
-                    log.info(
-                        _("Skipping [%(jid)s] which is not a gateway")
-                        % {"jid": entity.full()}
+                    log.debug(
+                        _("Skipping [{jid}] which is not a gateway").format(
+                            jid=entity.full()
+                        )
                     )
-        return ret
-
-    def _items_received(self, disco, target, client):
-        """Look for items with disco protocol, and ask infos for each one"""
-
-        if len(disco._items) == 0:
-            log.debug(_("No gateway found"))
-            return []
 
-        _defers = []
-        for item in disco._items:
-            log.debug(_("item found: %s") % item.entity)
-            _defers.append(client.disco.requestInfo(item.entity))
-        dl = defer.DeferredList(_defers)
-        dl.addCallback(
-            self._infos_received, items=disco._items, target=target, client=client
-        )
-        reactor.callLater(GATEWAY_TIMEOUT, dl.cancel)
-        return dl
-
-    def _find_gateways(self, target_jid_s, profile_key):
-        target_jid = jid.JID(target_jid_s)
-        profile = self.host.memory.get_profile_name(profile_key)
-        if not profile:
-            raise exceptions.ProfileUnknownError
-        d = self.gateways_find(target_jid, profile)
-        d.addCallback(self._gateways_result_2_xmlui, target_jid)
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    def gateways_find(self, target, profile):
-        """Find gateways in the target JID, using discovery protocol"""
-        client = self.host.get_client(profile)
-        log.debug(
-            _("find gateways (target = %(target)s, profile = %(profile)s)")
-            % {"target": target.full(), "profile": profile}
-        )
-        d = client.disco.requestItems(target)
-        d.addCallback(self._items_received, target=target, client=client)
-        return d
+        return ret