Mercurial > libervia-backend
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 ) |