Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_comp_email_gateway/imap.py @ 4303:a7ec325246fb
component email-gateway: first draft:
Initial implementation of the Email Gateway.
This component uses XEP-0100 for registration. Upon registration and subsequent startups,
a connection is made to registered IMAP services, and incoming emails (in `INBOX`
mailboxes) are immediately forwarded as XMPP messages.
In the opposite direction, an SMTP connection is established to send emails on incoming
XMPP messages.
rel 449
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 06 Sep 2024 18:07:17 +0200 |
parents | |
children |
comparison
equal
deleted
inserted
replaced
4302:9e7ea54b93ee | 4303:a7ec325246fb |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia Email Gateway Component | |
4 # Copyright (C) 2009-2024 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
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/>. | |
18 | |
19 from email.message import EmailMessage | |
20 from email.parser import BytesParser, Parser | |
21 from email import policy | |
22 from typing import Callable, cast | |
23 from twisted.internet import defer, protocol, reactor | |
24 from twisted.internet.base import DelayedCall | |
25 from twisted.mail import imap4 | |
26 from twisted.python.failure import Failure | |
27 | |
28 from libervia.backend.core import exceptions | |
29 from libervia.backend.core.i18n import _ | |
30 from libervia.backend.core.log import getLogger | |
31 from .models import UserData | |
32 | |
33 log = getLogger(__name__) | |
34 | |
35 | |
36 class IMAPClient(imap4.IMAP4Client): | |
37 _idling = False | |
38 _idle_timer: DelayedCall | None = None | |
39 | |
40 def __init__(self, connected: defer.Deferred, *args, **kwargs) -> None: | |
41 super().__init__(*args, **kwargs) | |
42 self._connected = connected | |
43 | |
44 def serverGreeting(self, caps: dict) -> None: | |
45 """Handle the server greeting and capabilities. | |
46 | |
47 @param caps: Server capabilities. | |
48 """ | |
49 defer.ensureDeferred(self.on_server_greeting(caps)) | |
50 | |
51 async def on_server_greeting(self, caps: dict) -> None: | |
52 """Async method called when server greeting is received. | |
53 | |
54 @param caps: Server capabilities. | |
55 """ | |
56 self.server_capabilities = caps | |
57 try: | |
58 await self.authenticate(self.factory.password.encode()) | |
59 except Exception as e: | |
60 log.warning(f"Can't authenticate: {e}") | |
61 self._connected.errback( | |
62 exceptions.PasswordError("Authentication error for IMAP server.") | |
63 ) | |
64 return | |
65 log.debug("Authenticated.") | |
66 self._connected.callback(None) | |
67 if b"IDLE" in caps: | |
68 # We use "examine" for read-only access for now, will probably change in the | |
69 # future. | |
70 await self.examine(b"INBOX") | |
71 log.debug("Activating IDLE mode") | |
72 await self.idle() | |
73 else: | |
74 log.warning( | |
75 f'"IDLE" mode is not supported by your server, this gateways needs a ' | |
76 "server supporting this mode." | |
77 ) | |
78 return | |
79 | |
80 async def idle(self) -> None: | |
81 """Enter the IDLE mode to receive real-time updates from the server.""" | |
82 if self._idling: | |
83 # We are already in idle state. | |
84 return | |
85 self._idling = True | |
86 self._idle_timer = reactor.callLater(29 * 60, self.on_idle_timeout) | |
87 await self.sendCommand( | |
88 imap4.Command( | |
89 b"IDLE", | |
90 continuation=lambda *a, **kw: log.debug(f"continuation: {a=} {kw=}"), | |
91 ) | |
92 ) | |
93 | |
94 def idle_exit(self) -> None: | |
95 """Exit the IDLE mode.""" | |
96 assert self._idling | |
97 assert self._idle_timer is not None | |
98 if not self._idle_timer.called: | |
99 self._idle_timer.cancel() | |
100 self._idle_timer = None | |
101 # Send DONE command to exit IDLE mode. | |
102 self.sendLine(b"DONE") | |
103 self._idling = False | |
104 log.debug("IDLE mode terminated") | |
105 | |
106 def on_idle_timeout(self): | |
107 """Called when IDLE mode timeout is reached.""" | |
108 if self._idling: | |
109 # We've reached 29 min of IDLE mode, we restart it as recommended in the | |
110 # specifications. | |
111 self.idle_exit() | |
112 defer.ensureDeferred(self.idle()) | |
113 | |
114 def newMessages(self, exists: int | None, recent: int | None): | |
115 """Called when new messages are received. | |
116 | |
117 @param exists: Number of existing messages. | |
118 @param recent: Number of recent messages. | |
119 """ | |
120 defer.ensureDeferred(self.on_new_emails(exists, recent)) | |
121 | |
122 async def on_new_emails(self, exists: int | None, recent: int | None) -> None: | |
123 """Async method called when new messages are received. | |
124 | |
125 @param exists: Number of existing messages. | |
126 @param recent: Number of recent messages. | |
127 """ | |
128 log.debug(f"New messages: {exists}, Recent messages: {recent}") | |
129 log.debug("Retrieving last message.") | |
130 self.idle_exit() | |
131 mess_data = await self.fetchMessage("*") | |
132 for message in mess_data.values(): | |
133 try: | |
134 content = message["RFC822"] | |
135 except KeyError: | |
136 log.warning(f"Can't find content for {message}.") | |
137 continue | |
138 else: | |
139 if isinstance(content, str): | |
140 parser = Parser(policy=policy.default) | |
141 parser_method = parser.parsestr | |
142 elif isinstance(content, bytes): | |
143 parser = BytesParser(policy=policy.default) | |
144 parser_method = parser.parsebytes | |
145 else: | |
146 log.error(f"Invalid content: {content}") | |
147 continue | |
148 try: | |
149 parsed = parser_method(content) | |
150 except Exception as e: | |
151 log.warning(f"Can't parse content of email: {e}") | |
152 continue | |
153 else: | |
154 assert self.factory is not None | |
155 factory = cast(IMAPClientFactory, self.factory) | |
156 await factory.on_new_email(parsed) | |
157 | |
158 defer.ensureDeferred(self.idle()) | |
159 | |
160 def connectionLost(self, reason: Failure) -> None: | |
161 """Called when the connection is lost. | |
162 | |
163 @param reason: The reason for the lost connection. | |
164 """ | |
165 log.debug(f"connectionLost {reason=}") | |
166 if not self._connected.called: | |
167 self._connected.errback(reason) | |
168 super().connectionLost(reason) | |
169 | |
170 def lineReceived(self, line: bytes) -> None: | |
171 """Called when a line is received from the server. | |
172 | |
173 @param line: The received line. | |
174 """ | |
175 if self._idling: | |
176 if line == b"* OK Still here": | |
177 pass | |
178 elif line == b"+ idling": | |
179 pass | |
180 elif line.startswith(b"* "): | |
181 # Handle unsolicited responses during IDLE | |
182 self._extraInfo([imap4.parseNestedParens(line[2:])]) | |
183 else: | |
184 log.warning(f"Unexpected line received: {line!r}") | |
185 | |
186 return | |
187 | |
188 return super().lineReceived(line) | |
189 | |
190 def sendCommand(self, cmd: imap4.Command) -> defer.Deferred: | |
191 """Send a command to the server. | |
192 | |
193 This method is overriden to stop and restart IDLE mode when a command is received. | |
194 | |
195 @param cmd: The command to send. | |
196 @return: A deferred that fires when the command is sent. | |
197 """ | |
198 if self._idling and cmd.command != b"IDLE": | |
199 self.idle_exit() | |
200 d = super().sendCommand(cmd) | |
201 | |
202 def restart_idle_mode(ret): | |
203 defer.ensureDeferred(self.idle()) | |
204 return ret | |
205 | |
206 d.addCallback(restart_idle_mode) | |
207 return d | |
208 else: | |
209 return super().sendCommand(cmd) | |
210 | |
211 | |
212 class IMAPClientFactory(protocol.ClientFactory): | |
213 protocol = IMAPClient | |
214 | |
215 def __init__( | |
216 self, | |
217 user_data: UserData, | |
218 on_new_email: Callable[[EmailMessage], None], | |
219 connected: defer.Deferred, | |
220 ) -> None: | |
221 """Initialize the IMAP client factory. | |
222 | |
223 @param username: The username to use for authentication. | |
224 @param password: The password to use for authentication. | |
225 """ | |
226 credentials = user_data.credentials | |
227 self.user_data = user_data | |
228 self.username = credentials["imap_username"] | |
229 self.password = credentials["imap_password"] | |
230 self.on_new_email = on_new_email | |
231 self._connected = connected | |
232 | |
233 def buildProtocol(self, addr) -> IMAPClient: | |
234 """Build the IMAP client protocol. | |
235 | |
236 @return: The IMAP client protocol. | |
237 """ | |
238 assert self.protocol is not None | |
239 assert isinstance(self.protocol, type(IMAPClient)) | |
240 protocol_ = self.protocol(self._connected) | |
241 protocol_.factory = self | |
242 self.user_data.imap_client = protocol_ | |
243 assert isinstance(protocol_, IMAPClient) | |
244 protocol_.factory = self | |
245 encoded_username = self.username.encode() | |
246 | |
247 protocol_.registerAuthenticator(imap4.PLAINAuthenticator(encoded_username)) | |
248 protocol_.registerAuthenticator(imap4.LOGINAuthenticator(encoded_username)) | |
249 protocol_.registerAuthenticator( | |
250 imap4.CramMD5ClientAuthenticator(encoded_username) | |
251 ) | |
252 | |
253 return protocol_ | |
254 | |
255 def clientConnectionFailed(self, connector, reason: Failure) -> None: | |
256 """Called when the client connection fails. | |
257 | |
258 @param reason: The reason for the failure. | |
259 """ | |
260 log.warning(f"Connection failed: {reason}") |