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
This commit is contained in:
Goffi 2024-09-26 16:12:01 +02:00
commit 15b29444a7

View file

@ -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.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(