comparison libervia/backend/plugins/plugin_comp_email_gateway/__init__.py @ 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 a7ec325246fb
children 055930cc81f9
comparison
equal deleted inserted replaced
4308:472a938a46e3 4309:b56b1eae7994
14 # GNU Affero General Public License for more details. 14 # GNU Affero General Public License for more details.
15 15
16 # You should have received a copy of the GNU Affero General Public License 16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. 17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18 18
19 from dataclasses import dataclass
20 from email.header import decode_header 19 from email.header import decode_header
21 from email.message import EmailMessage 20 from email.message import EmailMessage
22 from email.mime.text import MIMEText 21 from email.mime.text import MIMEText
23 from email.utils import formataddr, parseaddr 22 from email.utils import formataddr, getaddresses, parseaddr
24 from functools import partial 23 from functools import partial
25 import re 24 import re
26 from typing import Any, cast 25 from typing import cast
27 26
27 from pydantic import BaseModel
28 from twisted.internet import defer, reactor 28 from twisted.internet import defer, reactor
29 from twisted.mail import smtp 29 from twisted.mail import smtp
30 from twisted.words.protocols.jabber import jid 30 from twisted.words.protocols.jabber import jid
31 from twisted.words.protocols.jabber import error as jabber_error
31 from twisted.words.protocols.jabber.error import StanzaError 32 from twisted.words.protocols.jabber.error import StanzaError
32 from twisted.words.protocols.jabber.xmlstream import XMPPHandler 33 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
33 from twisted.words.xish import domish 34 from twisted.words.xish import domish
34 from wokkel import data_form, disco, iwokkel 35 from wokkel import data_form, disco, iwokkel
35 from zope.interface import implementer 36 from zope.interface import implementer
41 from libervia.backend.core.log import getLogger 42 from libervia.backend.core.log import getLogger
42 from libervia.backend.memory.persistent import LazyPersistentBinaryDict 43 from libervia.backend.memory.persistent import LazyPersistentBinaryDict
43 from libervia.backend.memory.sqla import select 44 from libervia.backend.memory.sqla import select
44 from libervia.backend.memory.sqla_mapping import PrivateIndBin 45 from libervia.backend.memory.sqla_mapping import PrivateIndBin
45 from libervia.backend.models.core import MessageData 46 from libervia.backend.models.core import MessageData
47 from libervia.backend.plugins.plugin_xep_0033 import (
48 RECIPIENT_FIELDS,
49 AddressType,
50 AddressesData,
51 )
46 from libervia.backend.plugins.plugin_xep_0077 import XEP_0077 52 from libervia.backend.plugins.plugin_xep_0077 import XEP_0077
47 from libervia.backend.plugins.plugin_xep_0106 import XEP_0106 53 from libervia.backend.plugins.plugin_xep_0106 import XEP_0106
48 from libervia.backend.tools.utils import aio 54 from libervia.backend.tools.utils import aio
49 55
50 from .models import Credentials, UserData 56 from .models import Credentials, UserData
60 C.PI_NAME: "Email Gateway Component", 66 C.PI_NAME: "Email Gateway Component",
61 C.PI_IMPORT_NAME: IMPORT_NAME, 67 C.PI_IMPORT_NAME: IMPORT_NAME,
62 C.PI_MODES: [C.PLUG_MODE_COMPONENT], 68 C.PI_MODES: [C.PLUG_MODE_COMPONENT],
63 C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT, 69 C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
64 C.PI_PROTOCOLS: [], 70 C.PI_PROTOCOLS: [],
65 C.PI_DEPENDENCIES: ["XEP-0077", "XEP-0106"], 71 C.PI_DEPENDENCIES: ["XEP-0033", "XEP-0077", "XEP-0106"],
66 C.PI_RECOMMENDATIONS: [], 72 C.PI_RECOMMENDATIONS: [],
67 C.PI_MAIN: "EmailGatewayComponent", 73 C.PI_MAIN: "EmailGatewayComponent",
68 C.PI_HANDLER: C.BOOL_TRUE, 74 C.PI_HANDLER: C.BOOL_TRUE,
69 C.PI_DESCRIPTION: D_( 75 C.PI_DESCRIPTION: D_(
70 "Gateway to handle email. Usual emails are handled as message, while mailing " 76 "Gateway to handle email. Usual emails are handled as message, while mailing "
75 CONF_SECTION = f"component {IMPORT_NAME}" 81 CONF_SECTION = f"component {IMPORT_NAME}"
76 PREFIX_KEY_CREDENTIALS = "CREDENTIALS_" 82 PREFIX_KEY_CREDENTIALS = "CREDENTIALS_"
77 KEY_CREDENTIALS = f"{PREFIX_KEY_CREDENTIALS}{{from_jid}}" 83 KEY_CREDENTIALS = f"{PREFIX_KEY_CREDENTIALS}{{from_jid}}"
78 84
79 email_pattern = re.compile(r"[^@]+@[^@]+\.[^@]+") 85 email_pattern = re.compile(r"[^@]+@[^@]+\.[^@]+")
86
87
88 class SendMailExtra(BaseModel):
89 addresses: AddressesData | None = None
80 90
81 91
82 class EmailGatewayComponent: 92 class EmailGatewayComponent:
83 IMPORT_NAME = IMPORT_NAME 93 IMPORT_NAME = IMPORT_NAME
84 verbose = 0 94 verbose = 0
180 @return: Message data. 190 @return: Message data.
181 """ 191 """
182 if client != self.client: 192 if client != self.client:
183 return mess_data 193 return mess_data
184 from_jid = mess_data["from"].userhostJID() 194 from_jid = mess_data["from"].userhostJID()
195 extra = None
185 if mess_data["type"] not in ("chat", "normal"): 196 if mess_data["type"] not in ("chat", "normal"):
186 log.warning(f"ignoring message with unexpected type: {mess_data}") 197 log.warning(f"ignoring message with unexpected type: {mess_data}")
187 return mess_data 198 return mess_data
188 if not client.is_local(from_jid): 199 if not client.is_local(from_jid):
189 log.warning(f"ignoring non local message: {mess_data}") 200 log.warning(f"ignoring non local message: {mess_data}")
190 return mess_data 201 return mess_data
191 if not mess_data["to"].user: 202 if not mess_data["to"].user:
192 log.warning(f"ignoring message addressed to gateway itself: {mess_data}") 203 addresses = mess_data["extra"].get("addresses")
193 return mess_data 204 if not addresses:
194 205 log.warning(f"ignoring message addressed to gateway itself: {mess_data}")
195 try: 206 return mess_data
196 to_email = self._e.unescape(mess_data["to"].user) 207 else:
197 except ValueError: 208 to_email = None
198 raise exceptions.DataError( 209 extra = SendMailExtra(addresses=addresses)
199 f'Invalid "to" JID, can\'t send message: {message_elt.toXml()}.' 210 else:
200 ) 211 try:
212 to_email = self._e.unescape(mess_data["to"].user)
213 except ValueError:
214 raise exceptions.DataError(
215 f'Invalid "to" JID, can\'t send message: {message_elt.toXml()}.'
216 )
201 217
202 try: 218 try:
203 body_lang, body = next(iter(mess_data["message"].items())) 219 body_lang, body = next(iter(mess_data["message"].items()))
204 except (KeyError, StopIteration): 220 except (KeyError, StopIteration):
205 log.warning(f"No body found: {mess_data}") 221 log.warning(f"No body found: {mess_data}")
217 await self.send_email( 233 await self.send_email(
218 from_jid=from_jid, 234 from_jid=from_jid,
219 to_email=to_email, 235 to_email=to_email,
220 body=body, 236 body=body,
221 subject=subject, 237 subject=subject,
238 extra=extra,
222 ) 239 )
223 except exceptions.UnknownEntityError: 240 except exceptions.UnknownEntityError:
224 log.warning(f"Can't send message, user {from_jid} is not registered.") 241 log.warning(f"Can't send message, user {from_jid} is not registered.")
225 message_error_elt = StanzaError( 242 message_error_elt = StanzaError(
226 "subscription-required", 243 "subscription-required",
227 text="User need to register to the gateway before sending emails.", 244 text="User need to register to the gateway before sending emails.",
228 ).toResponse(message_elt) 245 ).toResponse(message_elt)
229 await client.a_send(message_error_elt) 246 await client.a_send(message_error_elt)
230
231 raise exceptions.CancelError("User not registered.") 247 raise exceptions.CancelError("User not registered.")
248 except StanzaError as e:
249 log.warning("Can't send message: {e}")
250 message_error_elt = e.toResponse(message_elt)
251 await client.a_send(message_error_elt)
252 raise exceptions.CancelError("Can't send message: {e}")
232 253
233 return mess_data 254 return mess_data
255
256 def jid_to_email(
257 self, client: SatXMPPEntity, address_jid: jid.JID, credentials: dict[str, str]
258 ) -> str:
259 """Convert a JID to an email address.
260
261 If JID is from the gateway, email address will be extracted. Otherwise, the
262 gateway email will be used, with XMPP address specified in name part.
263
264 @param address_jid: JID of the recipient.
265 @param credentials: Sender credentials.
266 @return: Email address.
267 """
268 if address_jid and address_jid.host.endswith(str(client.jid)):
269 return self._e.unescape(address_jid.user)
270 else:
271 email_address = credentials["user_email"]
272 if address_jid:
273 email_address = formataddr((f"xmpp:{address_jid}", email_address))
274 return email_address
234 275
235 async def send_email( 276 async def send_email(
236 self, 277 self,
237 from_jid: jid.JID, 278 from_jid: jid.JID,
238 to_email: str, 279 to_email: str | None,
239 body: str, 280 body: str,
240 subject: str | None, 281 subject: str | None,
282 extra: SendMailExtra | None = None,
241 ) -> None: 283 ) -> None:
242 """Send an email using sender credentials. 284 """Send an email using sender credentials.
243 285
244 Credentials will be retrieve from cache, or database. 286 Credentials will be retrieve from cache, or database.
245 287
246 @param from_jid: Bare JID of the sender. 288 @param from_jid: Bare JID of the sender.
247 @param to_email: Email address of the destinee. 289 @param to_email: Email address of the destinee.
248 @param body: Body of the email. 290 @param body: Body of the email.
249 @param subject: Subject of the email. 291 @param subject: Subject of the email.
292 @param extra: Extra data.
250 293
251 @raise exceptions.UnknownEntityError: Credentials for "from_jid" can't be found. 294 @raise exceptions.UnknownEntityError: Credentials for "from_jid" can't be found.
252 """ 295 """
296 assert self.client is not None
297 if extra is None:
298 extra = SendMailExtra()
299 if to_email is None and (extra.addresses is None or not extra.addresses.to):
300 raise exceptions.InternalError(
301 '"to_email" can\'t be None if there is no "to" address!'
302 )
303
253 # We need a bare jid. 304 # We need a bare jid.
254 assert self.storage is not None 305 assert self.storage is not None
255 assert not from_jid.resource 306 assert not from_jid.resource
256 try: 307 try:
257 user_data = self.users_data[from_jid] 308 user_data = self.users_data[from_jid]
270 if subject is not None: 321 if subject is not None:
271 msg["Subject"] = subject 322 msg["Subject"] = subject
272 msg["From"] = formataddr( 323 msg["From"] = formataddr(
273 (credentials["user_name"] or None, credentials["user_email"]) 324 (credentials["user_name"] or None, credentials["user_email"])
274 ) 325 )
275 msg["To"] = to_email 326 if extra.addresses:
327 assert extra.addresses.to
328 main_to_address = extra.addresses.to[0]
329 assert main_to_address.jid
330 to_email = self.jid_to_email(self.client, main_to_address.jid, credentials)
331 for field in RECIPIENT_FIELDS:
332 addresses = getattr(extra.addresses, field)
333 if not addresses:
334 continue
335 for address in addresses:
336 if not address.delivered and (
337 address.jid is None or address.jid.host != str(self.client.jid)
338 ):
339 log.warning(
340 "Received undelivered message to external JID, this is not "
341 "allowed! Cancelling the message sending."
342 )
343 stanza_err = jabber_error.StanzaError(
344 "forbidden",
345 text="Multicasting (XEP-0033 addresses) can only be used "
346 "with JID from this gateway, not external ones. "
347 f" {address.jid} can't be delivered by this gateway and "
348 "should be delivered by server instead.",
349 )
350 raise stanza_err
351 email_addresses = [
352 self.jid_to_email(self.client, address.jid, credentials)
353 for address in addresses
354 if address.jid
355 ]
356 if email_addresses:
357 msg[field.upper()] = ", ".join(email_addresses)
358 else:
359 assert to_email is not None
360 msg["To"] = to_email
276 361
277 sender_domain = credentials["user_email"].split("@", 1)[-1] 362 sender_domain = credentials["user_email"].split("@", 1)[-1]
278 363
279 await smtp.sendmail( 364 await smtp.sendmail(
280 credentials["smtp_host"].encode(), 365 credentials["smtp_host"].encode(),
491 submit_form, "smtp_port", "int", min_value=1, max_value=65535, default=587 576 submit_form, "smtp_port", "int", min_value=1, max_value=65535, default=587
492 ) 577 )
493 self.validate_field(submit_form, "smtp_username", "str") 578 self.validate_field(submit_form, "smtp_username", "str")
494 self.validate_field(submit_form, "smtp_password", "str") 579 self.validate_field(submit_form, "smtp_password", "str")
495 580
496 async def on_new_email(self, to_jid: jid.JID, email: EmailMessage) -> None: 581 def email_to_jid(
582 self,
583 client: SatXMPPEntity,
584 user_email: str,
585 user_jid: jid.JID,
586 email_name: str,
587 email_addr: str,
588 ) -> tuple[jid.JID, str | None]:
589 """Convert an email address to a JID and extract the name if present.
590
591 @param client: Client session.
592 @param user_email: Email address of the gateway user.
593 @param user_jid: JID of the gateway user.
594 @param email_name: Email associated name.
595 @param email_addr: Email address.
596 @return: Tuple of JID and name (if present).
597 """
598 email_name = email_name.strip()
599 if email_name.startswith("xmpp:"):
600 return jid.JID(email_name[5:]), None
601 elif email_addr == user_email:
602 return (user_jid, None)
603 else:
604 return (
605 jid.JID(None, (self._e.escape(email_addr), client.jid.host, None)),
606 email_name or None,
607 )
608
609 async def on_new_email(
610 self, user_data: UserData, user_jid: jid.JID, email: EmailMessage
611 ) -> None:
497 """Called when a new message has been received. 612 """Called when a new message has been received.
498 613
499 @param to_jid: JID of the recipient. 614 @param user_data: user data, used to map registered user email to corresponding
615 jid.
616 @param user_jid: JID of the recipient.
500 @param email: Parsed email. 617 @param email: Parsed email.
501 """ 618 """
502 assert self.client is not None 619 assert self.client is not None
620 user_email = user_data.credentials["user_email"]
503 name, email_addr = parseaddr(email["from"]) 621 name, email_addr = parseaddr(email["from"])
504 email_addr = email_addr.lower() 622 email_addr = email_addr.lower()
505 from_jid = jid.JID(None, (self._e.escape(email_addr), self.client.jid.host, None)) 623 from_jid = jid.JID(None, (self._e.escape(email_addr), self.client.jid.host, None))
506 624
507 # Get the email body 625 # Get the email body
524 ] 642 ]
525 ).strip() 643 ).strip()
526 else: 644 else:
527 subject = None 645 subject = None
528 646
647 # Parse recipient fields
648 kwargs = {}
649 for field in RECIPIENT_FIELDS:
650 email_addresses = email.get_all(field)
651 if email_addresses:
652 jids_and_names = [
653 self.email_to_jid(self.client, user_email, user_jid, name, addr)
654 for name, addr in getaddresses(email_addresses)
655 ]
656 kwargs[field] = [
657 AddressType(jid=jid, desc=name) for jid, name in jids_and_names
658 ]
659
660 # At least "to" header should be set, so kwargs should never be empty
661 assert kwargs
662 addresses_data = AddressesData(**kwargs)
663
664 # Parse reply-to field
665 reply_to_addresses = email.get_all("reply-to")
666 if reply_to_addresses:
667 jids_with_names = [
668 self.email_to_jid(self.client, user_email, user_jid, name, addr)
669 for name, addr in getaddresses(reply_to_addresses)
670 ]
671 addresses_data.replyto = [
672 AddressType(jid=jid, desc=name) for jid, name in jids_with_names
673 ]
674
675 # Set noreply flag
676 # The is no flag to indicate a no-reply message, so we check common user parts in
677 # from and reply-to headers.
678 from_addresses = [email_addr]
679 if reply_to_addresses:
680 from_addresses.extend(
681 addr for a in reply_to_addresses if (addr := parseaddr(a)[1])
682 )
683 for from_address in from_addresses:
684 from_user_part = from_address.split("@", 1)[0].lower()
685 if from_user_part in (
686 "no-reply",
687 "noreply",
688 "do-not-reply",
689 "donotreply",
690 "notification",
691 "notifications",
692 ):
693 addresses_data.noreply = True
694 break
695
696 if (
697 not addresses_data.replyto
698 and not addresses_data.noreply
699 and not addresses_data.cc
700 and not addresses_data.bcc
701 and addresses_data.to == [AddressType(jid=user_jid)]
702 ):
703 # The main recipient is the only one, and there is no other metadata: there is
704 # no need to add addresses metadata.
705 extra = None
706 else:
707 for address in addresses_data.addresses:
708 if address.jid and (
709 address.jid == user_jid or address.jid.host == str(self.client.jid)
710 ):
711 # Those are email address, and have been delivered by the sender,
712 # other JID addresses will have to be delivered by us.
713 address.delivered = True
714
715 extra = {
716 "addresses": addresses_data.model_dump(mode="json", exclude_none=True)
717 }
718
529 client = self.client.get_virtual_client(from_jid) 719 client = self.client.get_virtual_client(from_jid)
530 await client.sendMessage(to_jid, {"": body}, {"": subject} if subject else None) 720 await client.sendMessage(
721 user_jid,
722 {"": body},
723 {"": subject} if subject else None,
724 extra=extra,
725 )
531 726
532 async def connect_imap(self, from_jid: jid.JID, user_data: UserData) -> None: 727 async def connect_imap(self, from_jid: jid.JID, user_data: UserData) -> None:
533 """Connect to IMAP service. 728 """Connect to IMAP service.
534 729
535 [self.on_new_email] will be used as callback on new messages. 730 [self.on_new_email] will be used as callback on new messages.
540 credentials = user_data.credentials 735 credentials = user_data.credentials
541 736
542 connected = defer.Deferred() 737 connected = defer.Deferred()
543 factory = IMAPClientFactory( 738 factory = IMAPClientFactory(
544 user_data, 739 user_data,
545 partial(self.on_new_email, from_jid.userhostJID()), 740 partial(self.on_new_email, user_data, from_jid.userhostJID()),
546 connected, 741 connected,
547 ) 742 )
548 reactor.connectTCP( 743 reactor.connectTCP(
549 credentials["imap_host"], int(credentials["imap_port"]), factory 744 credentials["imap_host"], int(credentials["imap_port"]), factory
550 ) 745 )