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