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}")