comparison libervia/backend/plugins/plugin_comp_email_gateway/__init__.py @ 4337:95792a1f26c7

component email gateway: attachments handling: attachments are now stored, and metadata are created in database. rel 453
author Goffi <goffi@goffi.org>
date Tue, 03 Dec 2024 00:13:23 +0100
parents 055930cc81f9
children 7c0b7ecb816f
comparison
equal deleted inserted replaced
4336:6e0918e638ee 4337:95792a1f26c7
19 from email.header import decode_header 19 from email.header import decode_header
20 from email.message import EmailMessage 20 from email.message import EmailMessage
21 from email.mime.text import MIMEText 21 from email.mime.text import MIMEText
22 from email.utils import formataddr, getaddresses, parseaddr 22 from email.utils import formataddr, getaddresses, parseaddr
23 from functools import partial 23 from functools import partial
24 import hashlib
25 from pathlib import Path
24 import re 26 import re
25 from typing import cast 27 import shutil
28 import tempfile
29 from typing import TYPE_CHECKING, NamedTuple, cast
26 30
27 from pydantic import BaseModel 31 from pydantic import BaseModel
28 from twisted.internet import defer, reactor 32 from twisted.internet import defer, reactor
33 from twisted.internet.threads import deferToThread
29 from twisted.mail import smtp 34 from twisted.mail import smtp
30 from twisted.words.protocols.jabber import jid 35 from twisted.words.protocols.jabber import jid
31 from twisted.words.protocols.jabber import error as jabber_error 36 from twisted.words.protocols.jabber import error as jabber_error
32 from twisted.words.protocols.jabber.error import StanzaError 37 from twisted.words.protocols.jabber.error import StanzaError
33 from twisted.words.protocols.jabber.xmlstream import XMPPHandler 38 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
35 from wokkel import data_form, disco, iwokkel 40 from wokkel import data_form, disco, iwokkel
36 from zope.interface import implementer 41 from zope.interface import implementer
37 42
38 from libervia.backend.core import exceptions 43 from libervia.backend.core import exceptions
39 from libervia.backend.core.constants import Const as C 44 from libervia.backend.core.constants import Const as C
40 from libervia.backend.core.core_types import SatXMPPEntity 45 from libervia.backend.core.core_types import SatXMPPComponent, SatXMPPEntity
41 from libervia.backend.core.i18n import D_, _ 46 from libervia.backend.core.i18n import D_, _
42 from libervia.backend.core.log import getLogger 47 from libervia.backend.core.log import getLogger
43 from libervia.backend.memory.persistent import LazyPersistentBinaryDict 48 from libervia.backend.memory.persistent import LazyPersistentBinaryDict
44 from libervia.backend.memory.sqla import select 49 from libervia.backend.memory.sqla import select
45 from libervia.backend.memory.sqla_mapping import PrivateIndBin 50 from libervia.backend.memory.sqla_mapping import PrivateIndBin
46 from libervia.backend.models.core import MessageData 51 from libervia.backend.models.core import MessageData
47 from libervia.backend.plugins.plugin_xep_0033 import ( 52 from libervia.backend.plugins.plugin_xep_0033 import (
48 RECIPIENT_FIELDS,
49 AddressType, 53 AddressType,
50 AddressesData, 54 AddressesData,
55 RECIPIENT_FIELDS,
51 ) 56 )
52 from libervia.backend.plugins.plugin_xep_0077 import XEP_0077 57 from libervia.backend.plugins.plugin_xep_0077 import XEP_0077
53 from libervia.backend.plugins.plugin_xep_0106 import XEP_0106 58 from libervia.backend.plugins.plugin_xep_0106 import XEP_0106
54 from libervia.backend.plugins.plugin_xep_0131 import XEP_0131, HeadersData, Urgency 59 from libervia.backend.plugins.plugin_xep_0131 import HeadersData, Urgency, XEP_0131
60 from libervia.backend.plugins.plugin_xep_0498 import XEP_0498
55 from libervia.backend.tools.utils import aio 61 from libervia.backend.tools.utils import aio
56 62
63 from .imap import IMAPClientFactory
57 from .models import Credentials, UserData 64 from .models import Credentials, UserData
58 from .imap import IMAPClientFactory 65
66 if TYPE_CHECKING:
67 from libervia.backend.core.main import LiberviaBackend
59 68
60 69
61 log = getLogger(__name__) 70 log = getLogger(__name__)
62 71
63 IMPORT_NAME = "email-gateway" 72 IMPORT_NAME = "email-gateway"
67 C.PI_NAME: "Email Gateway Component", 76 C.PI_NAME: "Email Gateway Component",
68 C.PI_IMPORT_NAME: IMPORT_NAME, 77 C.PI_IMPORT_NAME: IMPORT_NAME,
69 C.PI_MODES: [C.PLUG_MODE_COMPONENT], 78 C.PI_MODES: [C.PLUG_MODE_COMPONENT],
70 C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT, 79 C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
71 C.PI_PROTOCOLS: [], 80 C.PI_PROTOCOLS: [],
72 C.PI_DEPENDENCIES: ["XEP-0033", "XEP-0077", "XEP-0106"], 81 C.PI_DEPENDENCIES: ["XEP-0033", "XEP-0077", "XEP-0106", "XEP-0498"],
73 C.PI_RECOMMENDATIONS: [], 82 C.PI_RECOMMENDATIONS: [],
74 C.PI_MAIN: "EmailGatewayComponent", 83 C.PI_MAIN: "EmailGatewayComponent",
75 C.PI_HANDLER: C.BOOL_TRUE, 84 C.PI_HANDLER: C.BOOL_TRUE,
76 C.PI_DESCRIPTION: D_( 85 C.PI_DESCRIPTION: D_(
77 "Gateway to handle email. Usual emails are handled as message, while mailing " 86 "Gateway to handle email. Usual emails are handled as message, while mailing "
84 KEY_CREDENTIALS = f"{PREFIX_KEY_CREDENTIALS}{{from_jid}}" 93 KEY_CREDENTIALS = f"{PREFIX_KEY_CREDENTIALS}{{from_jid}}"
85 94
86 email_pattern = re.compile(r"[^@]+@[^@]+\.[^@]+") 95 email_pattern = re.compile(r"[^@]+@[^@]+\.[^@]+")
87 96
88 97
98 class FileMetadata(NamedTuple):
99 path: Path
100 hash: str
101 size: int
102
103
89 class SendMailExtra(BaseModel): 104 class SendMailExtra(BaseModel):
90 addresses: AddressesData | None = None 105 addresses: AddressesData | None = None
91 headers: HeadersData | None = None 106 headers: HeadersData | None = None
92 107
93 108
94 class EmailGatewayComponent: 109 class EmailGatewayComponent:
95 IMPORT_NAME = IMPORT_NAME 110 IMPORT_NAME = IMPORT_NAME
96 verbose = 0 111 verbose = 0
97 112
98 def __init__(self, host): 113 def __init__(self, host: "LiberviaBackend") -> None:
99 self.host = host 114 self.host = host
100 self.client: SatXMPPEntity | None = None 115 self.client: SatXMPPComponent | None = None
101 self.initalized = False 116 self.initalized = False
102 self.storage: LazyPersistentBinaryDict | None = None 117 self.storage: LazyPersistentBinaryDict | None = None
103 self._iq_register = cast(XEP_0077, host.plugins["XEP-0077"]) 118 self._iq_register = cast(XEP_0077, host.plugins["XEP-0077"])
104 self._iq_register.register_handler( 119 self._iq_register.register_handler(
105 self._on_registration_form, self._on_registration_submit 120 self._on_registration_form, self._on_registration_submit
106 ) 121 )
107 self._e = cast(XEP_0106, host.plugins["XEP-0106"]) 122 self._e = cast(XEP_0106, host.plugins["XEP-0106"])
108 self._shim = cast(XEP_0131, host.plugins["XEP-0131"]) 123 self._shim = cast(XEP_0131, host.plugins["XEP-0131"])
124 self._pfs = cast(XEP_0498, host.plugins["XEP-0498"])
109 # TODO: For the moment, all credentials are kept in cache; we should only keep the 125 # TODO: For the moment, all credentials are kept in cache; we should only keep the
110 # X latest. 126 # X latest.
111 self.users_data: dict[jid.JID, UserData] = {} 127 self.users_data: dict[jid.JID, UserData] = {}
128 self.files_path = self.host.get_local_path(None, C.FILES_DIR)
112 host.trigger.add_with_check( 129 host.trigger.add_with_check(
113 "message_received", self, self._message_received_trigger, priority=-1000 130 "message_received", self, self._message_received_trigger, priority=-1000
114 ) 131 )
115 132
116 async def _init(self) -> None: 133 async def _init(self) -> None:
160 177
161 def get_handler(self, __) -> XMPPHandler: 178 def get_handler(self, __) -> XMPPHandler:
162 return EmailGatewayHandler() 179 return EmailGatewayHandler()
163 180
164 async def profile_connecting(self, client: SatXMPPEntity) -> None: 181 async def profile_connecting(self, client: SatXMPPEntity) -> None:
182 assert isinstance(client, SatXMPPComponent)
165 self.client = client 183 self.client = client
166 if not self.initalized: 184 if not self.initalized:
167 await self._init() 185 await self._init()
168 self.initalized = True 186 self.initalized = True
169 187
754 if headers: 772 if headers:
755 extra["headers"] = HeadersData(**headers).model_dump( 773 extra["headers"] = HeadersData(**headers).model_dump(
756 mode="json", exclude_none=True 774 mode="json", exclude_none=True
757 ) 775 )
758 776
777 # Handle attachments
778 for part in email.iter_attachments():
779 await self.handle_attachment(part, user_jid)
780
759 client = self.client.get_virtual_client(from_jid) 781 client = self.client.get_virtual_client(from_jid)
782
760 await client.sendMessage( 783 await client.sendMessage(
761 user_jid, 784 user_jid,
762 {"": body}, 785 {"": body},
763 {"": subject} if subject else None, 786 {"": subject} if subject else None,
764 extra=extra, 787 extra=extra,
765 ) 788 )
789
790 async def handle_attachment(self, part: EmailMessage, recipient_jid: jid.JID) -> None:
791 """Handle an attachment from an email.
792
793 @param part: The object representing the attachment.
794 @param recipient_jid: JID of the recipient to whom the attachment is being sent.
795 """
796 assert self.client is not None
797 content_type = part.get_content_type()
798 filename = part.get_filename() or "attachment"
799 log.debug(f"Handling attachment: {filename} ({content_type})")
800 file_metadata = await deferToThread(self._save_attachment, part)
801 if file_metadata is not None:
802 log.debug(f"Attachment {filename!r} saved to {file_metadata.path}")
803 try:
804 await self.host.memory.set_file(
805 self.client,
806 filename,
807 file_hash=file_metadata.hash,
808 hash_algo="sha-256",
809 size=file_metadata.size,
810 namespace=PLUGIN_INFO[C.PI_IMPORT_NAME],
811 mime_type=content_type,
812 owner=recipient_jid,
813 )
814 except Exception:
815 log.exception(f"Failed to register file {filename!r}")
816
817 def _save_attachment(self, part: EmailMessage) -> FileMetadata | None:
818 """Save the attachment to files path.
819
820 This method must be executed in a thread with deferToThread to avoid blocking the
821 reactor with IO operations if the attachment is large.
822
823 @param part: The object representing the attachment.
824 @return: Attachment data, or None if an error occurs.
825 @raises IOError: Can't save the attachment.
826 """
827 temp_file = None
828 try:
829 with tempfile.NamedTemporaryFile(delete=False) as temp_file:
830 payload = part.get_payload(decode=True)
831 if isinstance(payload, bytes):
832 temp_file.write(payload)
833 file_hash = hashlib.sha256(payload).hexdigest()
834 file_path = self.files_path / file_hash
835 shutil.move(temp_file.name, file_path)
836 file_size = len(payload)
837 return FileMetadata(path=file_path, hash=file_hash, size=file_size)
838 else:
839 log.warning(f"Can't write payload of type {type(payload)}.")
840 return None
841 except Exception as e:
842 raise IOError(f"Failed to save attachment: {e}")
843 finally:
844 if temp_file is not None and Path(temp_file.name).exists():
845 Path(temp_file.name).unlink()
766 846
767 async def connect_imap(self, from_jid: jid.JID, user_data: UserData) -> None: 847 async def connect_imap(self, from_jid: jid.JID, user_data: UserData) -> None:
768 """Connect to IMAP service. 848 """Connect to IMAP service.
769 849
770 [self.on_new_email] will be used as callback on new messages. 850 [self.on_new_email] will be used as callback on new messages.