diff --git a/libervia/backend/plugins/plugin_comp_email_gateway/__init__.py b/libervia/backend/plugins/plugin_comp_email_gateway/__init__.py index e64df4f2..4315165a 100644 --- a/libervia/backend/plugins/plugin_comp_email_gateway/__init__.py +++ b/libervia/backend/plugins/plugin_comp_email_gateway/__init__.py @@ -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.persistent import LazyPersistentBinaryDict 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 @@ PLUGIN_INFO = { 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 @@ KEY_CREDENTIALS = f"{PREFIX_KEY_CREDENTIALS}{{from_jid}}" email_pattern = re.compile(r"[^@]+@[^@]+\.[^@]+") +class SendMailExtra(BaseModel): + addresses: AddressesData | None = None + + class EmailGatewayComponent: IMPORT_NAME = IMPORT_NAME verbose = 0 @@ -182,6 +192,7 @@ class EmailGatewayComponent: 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 @@ class EmailGatewayComponent: 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 @@ class EmailGatewayComponent: 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 @@ class EmailGatewayComponent: 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 @@ class EmailGatewayComponent: @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 @@ class EmailGatewayComponent: 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 @@ class EmailGatewayComponent: 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 @@ class EmailGatewayComponent: 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 @@ class EmailGatewayComponent: 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(