# HG changeset patch # User Goffi # Date 1727359921 -7200 # Node ID b56b1eae79945daa88630d768ea67abd8e8dcda3 # Parent 472a938a46e3bb053b369d389c03eb2c20b22d77 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 diff -r 472a938a46e3 -r b56b1eae7994 libervia/backend/plugins/plugin_comp_email_gateway/__init__.py --- 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 . -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(