changeset 4309:b56b1eae7994

component email gateway: add multicasting: XEP-0033 multicasting is now supported both for incoming and outgoing messages. XEP-0033 metadata are converted to suitable Email headers and vice versa. Email address and JID are both supported, and delivery is done by the gateway when suitable on incoming messages. rel 450
author Goffi <goffi@goffi.org>
date Thu, 26 Sep 2024 16:12:01 +0200
parents 472a938a46e3
children d27228b3c704
files libervia/backend/plugins/plugin_comp_email_gateway/__init__.py
diffstat 1 files changed, 215 insertions(+), 20 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/backend/plugins/plugin_comp_email_gateway/__init__.py	Thu Sep 26 16:12:01 2024 +0200
+++ b/libervia/backend/plugins/plugin_comp_email_gateway/__init__.py	Thu Sep 26 16:12:01 2024 +0200
@@ -16,18 +16,19 @@
 # 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 dataclasses import dataclass
 from email.header import decode_header
 from email.message import EmailMessage
 from email.mime.text import MIMEText
-from email.utils import formataddr, parseaddr
+from email.utils import formataddr, getaddresses, parseaddr
 from functools import partial
 import re
-from typing import Any, cast
+from typing import cast
 
+from pydantic import BaseModel
 from twisted.internet import defer, reactor
 from twisted.mail import smtp
 from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error as jabber_error
 from twisted.words.protocols.jabber.error import StanzaError
 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
 from twisted.words.xish import domish
@@ -43,6 +44,11 @@
 from libervia.backend.memory.sqla import select
 from libervia.backend.memory.sqla_mapping import PrivateIndBin
 from libervia.backend.models.core import MessageData
+from libervia.backend.plugins.plugin_xep_0033 import (
+    RECIPIENT_FIELDS,
+    AddressType,
+    AddressesData,
+)
 from libervia.backend.plugins.plugin_xep_0077 import XEP_0077
 from libervia.backend.plugins.plugin_xep_0106 import XEP_0106
 from libervia.backend.tools.utils import aio
@@ -62,7 +68,7 @@
     C.PI_MODES: [C.PLUG_MODE_COMPONENT],
     C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
     C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0077", "XEP-0106"],
+    C.PI_DEPENDENCIES: ["XEP-0033", "XEP-0077", "XEP-0106"],
     C.PI_RECOMMENDATIONS: [],
     C.PI_MAIN: "EmailGatewayComponent",
     C.PI_HANDLER: C.BOOL_TRUE,
@@ -79,6 +85,10 @@
 email_pattern = re.compile(r"[^@]+@[^@]+\.[^@]+")
 
 
+class SendMailExtra(BaseModel):
+    addresses: AddressesData | None = None
+
+
 class EmailGatewayComponent:
     IMPORT_NAME = IMPORT_NAME
     verbose = 0
@@ -182,6 +192,7 @@
         if client != self.client:
             return mess_data
         from_jid = mess_data["from"].userhostJID()
+        extra = None
         if mess_data["type"] not in ("chat", "normal"):
             log.warning(f"ignoring message with unexpected type: {mess_data}")
             return mess_data
@@ -189,15 +200,20 @@
             log.warning(f"ignoring non local message: {mess_data}")
             return mess_data
         if not mess_data["to"].user:
-            log.warning(f"ignoring message addressed to gateway itself: {mess_data}")
-            return mess_data
-
-        try:
-            to_email = self._e.unescape(mess_data["to"].user)
-        except ValueError:
-            raise exceptions.DataError(
-                f'Invalid "to" JID, can\'t send message: {message_elt.toXml()}.'
-            )
+            addresses = mess_data["extra"].get("addresses")
+            if not addresses:
+                log.warning(f"ignoring message addressed to gateway itself: {mess_data}")
+                return mess_data
+            else:
+                to_email = None
+                extra = SendMailExtra(addresses=addresses)
+        else:
+            try:
+                to_email = self._e.unescape(mess_data["to"].user)
+            except ValueError:
+                raise exceptions.DataError(
+                    f'Invalid "to" JID, can\'t send message: {message_elt.toXml()}.'
+                )
 
         try:
             body_lang, body = next(iter(mess_data["message"].items()))
@@ -219,6 +235,7 @@
                 to_email=to_email,
                 body=body,
                 subject=subject,
+                extra=extra,
             )
         except exceptions.UnknownEntityError:
             log.warning(f"Can't send message, user {from_jid} is not registered.")
@@ -227,17 +244,42 @@
                 text="User need to register to the gateway before sending emails.",
             ).toResponse(message_elt)
             await client.a_send(message_error_elt)
-
             raise exceptions.CancelError("User not registered.")
+        except StanzaError as e:
+            log.warning("Can't send message: {e}")
+            message_error_elt = e.toResponse(message_elt)
+            await client.a_send(message_error_elt)
+            raise exceptions.CancelError("Can't send message: {e}")
 
         return mess_data
 
+    def jid_to_email(
+        self, client: SatXMPPEntity, address_jid: jid.JID, credentials: dict[str, str]
+    ) -> str:
+        """Convert a JID to an email address.
+
+        If JID is from the gateway, email address will be extracted. Otherwise, the
+        gateway email will be used, with XMPP address specified in name part.
+
+        @param address_jid: JID of the recipient.
+        @param credentials: Sender credentials.
+        @return: Email address.
+        """
+        if address_jid and address_jid.host.endswith(str(client.jid)):
+            return self._e.unescape(address_jid.user)
+        else:
+            email_address = credentials["user_email"]
+            if address_jid:
+                email_address = formataddr((f"xmpp:{address_jid}", email_address))
+            return email_address
+
     async def send_email(
         self,
         from_jid: jid.JID,
-        to_email: str,
+        to_email: str | None,
         body: str,
         subject: str | None,
+        extra: SendMailExtra | None = None,
     ) -> None:
         """Send an email using sender credentials.
 
@@ -247,9 +289,18 @@
         @param to_email: Email address of the destinee.
         @param body: Body of the email.
         @param subject: Subject of the email.
+        @param extra: Extra data.
 
         @raise exceptions.UnknownEntityError: Credentials for "from_jid" can't be found.
         """
+        assert self.client is not None
+        if extra is None:
+            extra = SendMailExtra()
+        if to_email is None and (extra.addresses is None or not extra.addresses.to):
+            raise exceptions.InternalError(
+                '"to_email" can\'t be None if there is no "to" address!'
+            )
+
         # We need a bare jid.
         assert self.storage is not None
         assert not from_jid.resource
@@ -272,7 +323,41 @@
         msg["From"] = formataddr(
             (credentials["user_name"] or None, credentials["user_email"])
         )
-        msg["To"] = to_email
+        if extra.addresses:
+            assert extra.addresses.to
+            main_to_address = extra.addresses.to[0]
+            assert main_to_address.jid
+            to_email = self.jid_to_email(self.client, main_to_address.jid, credentials)
+            for field in RECIPIENT_FIELDS:
+                addresses = getattr(extra.addresses, field)
+                if not addresses:
+                    continue
+                for address in addresses:
+                    if not address.delivered and (
+                        address.jid is None or address.jid.host != str(self.client.jid)
+                    ):
+                        log.warning(
+                            "Received undelivered message to external JID, this is not "
+                            "allowed! Cancelling the message sending."
+                        )
+                        stanza_err = jabber_error.StanzaError(
+                            "forbidden",
+                            text="Multicasting (XEP-0033 addresses) can only be used "
+                            "with JID from this gateway, not external ones. "
+                            f" {address.jid} can't be delivered by this gateway and "
+                            "should be delivered by server instead.",
+                        )
+                        raise stanza_err
+                email_addresses = [
+                    self.jid_to_email(self.client, address.jid, credentials)
+                    for address in addresses
+                    if address.jid
+                ]
+                if email_addresses:
+                    msg[field.upper()] = ", ".join(email_addresses)
+        else:
+            assert to_email is not None
+            msg["To"] = to_email
 
         sender_domain = credentials["user_email"].split("@", 1)[-1]
 
@@ -493,13 +578,46 @@
         self.validate_field(submit_form, "smtp_username", "str")
         self.validate_field(submit_form, "smtp_password", "str")
 
-    async def on_new_email(self, to_jid: jid.JID, email: EmailMessage) -> None:
+    def email_to_jid(
+        self,
+        client: SatXMPPEntity,
+        user_email: str,
+        user_jid: jid.JID,
+        email_name: str,
+        email_addr: str,
+    ) -> tuple[jid.JID, str | None]:
+        """Convert an email address to a JID and extract the name if present.
+
+        @param client: Client session.
+        @param user_email: Email address of the gateway user.
+        @param user_jid: JID of the gateway user.
+        @param email_name: Email associated name.
+        @param email_addr: Email address.
+        @return: Tuple of JID and name (if present).
+        """
+        email_name = email_name.strip()
+        if email_name.startswith("xmpp:"):
+            return jid.JID(email_name[5:]), None
+        elif email_addr == user_email:
+            return (user_jid, None)
+        else:
+            return (
+                jid.JID(None, (self._e.escape(email_addr), client.jid.host, None)),
+                email_name or None,
+            )
+
+    async def on_new_email(
+        self, user_data: UserData, user_jid: jid.JID, email: EmailMessage
+    ) -> None:
         """Called when a new message has been received.
 
-        @param to_jid: JID of the recipient.
+        @param user_data: user data, used to map registered user email to corresponding
+            jid.
+        @param user_jid: JID of the recipient.
         @param email: Parsed email.
         """
         assert self.client is not None
+        user_email = user_data.credentials["user_email"]
         name, email_addr = parseaddr(email["from"])
         email_addr = email_addr.lower()
         from_jid = jid.JID(None, (self._e.escape(email_addr), self.client.jid.host, None))
@@ -526,8 +644,85 @@
         else:
             subject = None
 
+        # Parse recipient fields
+        kwargs = {}
+        for field in RECIPIENT_FIELDS:
+            email_addresses = email.get_all(field)
+            if email_addresses:
+                jids_and_names = [
+                    self.email_to_jid(self.client, user_email, user_jid, name, addr)
+                    for name, addr in getaddresses(email_addresses)
+                ]
+                kwargs[field] = [
+                    AddressType(jid=jid, desc=name) for jid, name in jids_and_names
+                ]
+
+        # At least "to" header should be set, so kwargs should never be empty
+        assert kwargs
+        addresses_data = AddressesData(**kwargs)
+
+        # Parse reply-to field
+        reply_to_addresses = email.get_all("reply-to")
+        if reply_to_addresses:
+            jids_with_names = [
+                self.email_to_jid(self.client, user_email, user_jid, name, addr)
+                for name, addr in getaddresses(reply_to_addresses)
+            ]
+            addresses_data.replyto = [
+                AddressType(jid=jid, desc=name) for jid, name in jids_with_names
+            ]
+
+        # Set noreply flag
+        # The is no flag to indicate a no-reply message, so we check common user parts in
+        # from and reply-to headers.
+        from_addresses = [email_addr]
+        if reply_to_addresses:
+            from_addresses.extend(
+                addr for a in reply_to_addresses if (addr := parseaddr(a)[1])
+            )
+        for from_address in from_addresses:
+            from_user_part = from_address.split("@", 1)[0].lower()
+            if from_user_part in (
+                "no-reply",
+                "noreply",
+                "do-not-reply",
+                "donotreply",
+                "notification",
+                "notifications",
+            ):
+                addresses_data.noreply = True
+                break
+
+        if (
+            not addresses_data.replyto
+            and not addresses_data.noreply
+            and not addresses_data.cc
+            and not addresses_data.bcc
+            and addresses_data.to == [AddressType(jid=user_jid)]
+        ):
+            # The main recipient is the only one, and there is no other metadata: there is
+            # no need to add addresses metadata.
+            extra = None
+        else:
+            for address in addresses_data.addresses:
+                if address.jid and (
+                    address.jid == user_jid or address.jid.host == str(self.client.jid)
+                ):
+                    # Those are email address, and have been delivered by the sender,
+                    # other JID addresses will have to be delivered by us.
+                    address.delivered = True
+
+            extra = {
+                "addresses": addresses_data.model_dump(mode="json", exclude_none=True)
+            }
+
         client = self.client.get_virtual_client(from_jid)
-        await client.sendMessage(to_jid, {"": body}, {"": subject} if subject else None)
+        await client.sendMessage(
+            user_jid,
+            {"": body},
+            {"": subject} if subject else None,
+            extra=extra,
+        )
 
     async def connect_imap(self, from_jid: jid.JID, user_data: UserData) -> None:
         """Connect to IMAP service.
@@ -542,7 +737,7 @@
         connected = defer.Deferred()
         factory = IMAPClientFactory(
             user_data,
-            partial(self.on_new_email, from_jid.userhostJID()),
+            partial(self.on_new_email, user_data, from_jid.userhostJID()),
             connected,
         )
         reactor.connectTCP(