comparison sat/plugins/plugin_misc_maildir.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents src/plugins/plugin_misc_maildir.py@33c8c4973743
children ab2696e34d29
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SàT plugin for managing Maildir type mail boxes
5 # Copyright (C) 2011 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat.core.i18n import D_, _
21 from sat.core.constants import Const as C
22 from sat.core.log import getLogger
23 log = getLogger(__name__)
24 import warnings
25 warnings.filterwarnings('ignore', 'the MimeWriter', DeprecationWarning, 'twisted') # FIXME: to be removed, see http://twistedmatrix.com/trac/ticket/4038
26 from twisted.mail import maildir
27 import email.message
28 import email.utils
29 import os
30 from sat.core.exceptions import ProfileUnknownError
31 from sat.memory.persistent import PersistentBinaryDict
32
33
34 PLUGIN_INFO = {
35 C.PI_NAME: "Maildir Plugin",
36 C.PI_IMPORT_NAME: "Maildir",
37 C.PI_TYPE: "Misc",
38 C.PI_PROTOCOLS: [],
39 C.PI_DEPENDENCIES: [],
40 C.PI_MAIN: "MaildirBox",
41 C.PI_HANDLER: "no",
42 C.PI_DESCRIPTION: _("""Intercept "normal" type messages, and put them in a Maildir type box""")
43 }
44
45 MAILDIR_PATH = "Maildir"
46 CATEGORY = D_("Mail Server")
47 NAME = D_('Block "normal" messages propagation')
48 # FIXME: (very) old and (very) experimental code, need a big cleaning/review or to be deprecated
49
50
51 class MaildirError(Exception):
52 pass
53
54
55 class MaildirBox(object):
56 params = """
57 <params>
58 <individual>
59 <category name='{category_name}' label='{category_label}'>
60 <param name='{name}' label='{label}' value="false" type="bool" security="4" />
61 </category>
62 </individual>
63 </params>
64 """.format(category_name=CATEGORY,
65 category_label=_(CATEGORY),
66 name=NAME,
67 label=_(NAME),
68 )
69
70 def __init__(self, host):
71 log.info(_("Plugin Maildir initialization"))
72 self.host = host
73 host.memory.updateParams(self.params)
74
75 self.__observed = {}
76 self.data = {} # list of profile spectific data. key = profile, value = PersistentBinaryDict where key=mailbox name,
77 # and value is a dictionnary with the following value
78 # - cur_idx: value of the current unique integer increment (UID)
79 # - message_id (as returned by MaildirMailbox): a tuple of (UID, [flag1, flag2, ...])
80 self.__mailboxes = {} # key: profile, value: {boxname: MailboxUser instance}
81
82 #the triggers
83 host.trigger.add("MessageReceived", self.messageReceivedTrigger)
84
85 def profileConnected(self, client):
86 """Called on client connection, create profile data"""
87 profile = client.profile
88 self.data[profile] = PersistentBinaryDict("plugin_maildir", profile)
89 self.__mailboxes[profile] = {}
90
91 def dataLoaded(ignore):
92 if not self.data[profile]:
93 #the mailbox is new, we initiate the data
94 self.data[profile]["INBOX"] = {"cur_idx": 0}
95 self.data[profile].load().addCallback(dataLoaded)
96
97 def profileDisconnected(self, client):
98 """Called on profile disconnection, free profile's resources"""
99 profile = client.profile
100 del self.__mailboxes[profile]
101 del self.data[profile]
102
103 def messageReceivedTrigger(self, client, message, post_treat):
104 """This trigger catch normal message and put the in the Maildir box.
105 If the message is not of "normal" type, do nothing
106 @param message: message xmlstrem
107 @return: False if it's a normal message, True else"""
108 profile = client.profile
109 for e in message.elements(C.NS_CLIENT, 'body'):
110 mess_type = message.getAttribute('type', 'normal')
111 if mess_type != 'normal':
112 return True
113 self.accessMessageBox("INBOX", profile_key=profile).addMessage(message)
114 return not self.host.memory.getParamA(NAME, CATEGORY, profile_key=profile)
115 return True
116
117 def accessMessageBox(self, boxname, observer=None, profile_key=C.PROF_KEY_NONE):
118 """Create and return a MailboxUser instance
119 @param boxname: name of the box
120 @param observer: method to call when a NewMessage arrive"""
121 profile = self.host.memory.getProfileName(profile_key)
122 if not profile:
123 raise ProfileUnknownError(profile_key)
124 if boxname not in self.__mailboxes[profile]:
125 self.__mailboxes[profile][boxname] = MailboxUser(self, boxname, observer, profile=profile)
126 else:
127 if observer:
128 self.addObserver(observer, profile, boxname)
129 return self.__mailboxes[profile][boxname]
130
131 def _getProfilePath(self, profile):
132 """Return a unique path for profile's mailbox
133 The path must be unique, usable as a dir name, and bijectional"""
134 return profile.replace('/', '_').replace('..', '_') # FIXME: this is too naive to work well, must be improved
135
136 def _removeBoxAccess(self, boxname, mailboxUser, profile):
137 """Remove a reference to a box
138 @param name: name of the box
139 @param mailboxUser: MailboxUser instance"""
140 if boxname not in self.__mailboxes:
141 err_msg = _("Trying to remove an mailboxUser not referenced")
142 log.error(_(u"INTERNAL ERROR: ") + err_msg)
143 raise MaildirError(err_msg)
144 assert self.__mailboxes[profile][boxname] == mailboxUser
145 del self.__mailboxes[profile][boxname]
146
147 def _checkBoxReference(self, boxname, profile):
148 """Check if there is a reference on a box, and return it
149 @param boxname: name of the box to check
150 @return: MailboxUser instance or None"""
151 if profile in self.__mailboxes:
152 if boxname in self.__mailboxes[profile]:
153 return self.__mailboxes[profile][boxname]
154
155 def __getBoxData(self, boxname, profile):
156 """Return the date of a box"""
157 try:
158 return self.data[profile][boxname] # the boxname MUST exist in the data
159 except KeyError:
160 err_msg = _("Boxname doesn't exist in internal data")
161 log.error(_(u"INTERNAL ERROR: ") + err_msg)
162 raise MaildirError(err_msg)
163
164 def getUid(self, boxname, message_id, profile):
165 """Return an unique integer, always ascending, for a message
166 This is mainly needed for the IMAP protocol
167 @param boxname: name of the box where the message is
168 @param message_id: unique id of the message as given by MaildirMailbox
169 @return: Integer UID"""
170 box_data = self.__getBoxData(boxname, profile)
171 if message_id in box_data:
172 ret = box_data[message_id][0]
173 else:
174 box_data['cur_idx'] += 1
175 box_data[message_id] = [box_data['cur_idx'], []]
176 ret = box_data[message_id]
177 self.data[profile].force(boxname)
178 return ret
179
180 def getNextUid(self, boxname, profile):
181 """Return next unique integer that will generated
182 This is mainly needed for the IMAP protocol
183 @param boxname: name of the box where the message is
184 @return: Integer UID"""
185 box_data = self.__getBoxData(boxname, profile)
186 return box_data['cur_idx'] + 1
187
188 def getNextExistingUid(self, boxname, uid, profile):
189 """Give the next uid of existing message
190 @param boxname: name of the box where the message is
191 @param uid: uid to start from
192 @return: uid or None if the is no more message"""
193 box_data = self.__getBoxData(boxname, profile)
194 idx = uid + 1
195 while self.getIdFromUid(boxname, idx, profile) is None: # TODO: this is highly inefficient because getIdfromUid is inefficient, fix this
196 idx += 1
197 if idx > box_data['cur_idx']:
198 return None
199 return idx
200
201 def getMaxUid(self, boxname, profile):
202 """Give the max existing uid
203 @param boxname: name of the box where the message is
204 @return: uid"""
205 box_data = self.__getBoxData(boxname, profile)
206 return box_data['cur_idx']
207
208 def getIdFromUid(self, boxname, message_uid, profile):
209 """Return the message unique id from it's integer UID
210 @param boxname: name of the box where the message is
211 @param message_uid: unique integer identifier
212 @return: unique id of the message as given by MaildirMailbox or None if not found"""
213 box_data = self.__getBoxData(boxname, profile)
214 for message_id in box_data.keys(): # TODO: this is highly inefficient on big mailbox, must be replaced in the future
215 if message_id == 'cur_idx':
216 continue
217 if box_data[message_id][0] == message_uid:
218 return message_id
219 return None
220
221 def getFlags(self, boxname, mess_id, profile):
222 """Return the messages flags
223 @param boxname: name of the box where the message is
224 @param message_idx: message id as given by MaildirMailbox
225 @return: list of strings"""
226 box_data = self.__getBoxData(boxname, profile)
227 if mess_id not in box_data:
228 raise MaildirError("Trying to get flags from an unexisting message")
229 return box_data[mess_id][1]
230
231 def setFlags(self, boxname, mess_id, flags, profile):
232 """Change the flags of the message
233 @param boxname: name of the box where the message is
234 @param message_idx: message id as given by MaildirMailbox
235 @param flags: list of strings
236 """
237 box_data = self.__getBoxData(boxname, profile)
238 assert(type(flags) == list)
239 flags = [flag.upper() for flag in flags] # we store every flag UPPERCASE
240 if mess_id not in box_data:
241 raise MaildirError("Trying to set flags for an unexisting message")
242 box_data[mess_id][1] = flags
243 self.data[profile].force(boxname)
244
245 def getMessageIdsWithFlag(self, boxname, flag, profile):
246 """Return ids of messages where a flag is set
247 @param boxname: name of the box where the message is
248 @param flag: flag to check
249 @return: list of id (as given by MaildirMailbox)"""
250 box_data = self.__getBoxData(boxname, profile)
251 assert(isinstance(flag, basestring))
252 flag = flag.upper()
253 result = []
254 for key in box_data:
255 if key == 'cur_idx':
256 continue
257 if flag in box_data[key][1]:
258 result.append(key)
259 return result
260
261 def purgeDeleted(self, boxname, profile):
262 """Remove data for messages with flag "\\Deleted"
263 @param boxname: name of the box where the message is
264 """
265 box_data = self.__getBoxData(boxname, profile)
266 for mess_id in self.getMessageIdsWithFlag(boxname, "\\Deleted", profile):
267 del(box_data[mess_id])
268 self.data[profile].force(boxname)
269
270 def cleanTable(self, boxname, existant_id, profile):
271 """Remove mails which no longuer exist from the table
272 @param boxname: name of the box to clean
273 @param existant_id: list of id which actually exist"""
274 box_data = self.__getBoxData(boxname, profile)
275 to_remove = []
276 for key in box_data:
277 if key not in existant_id and key != "cur_idx":
278 to_remove.append(key)
279 for key in to_remove:
280 del box_data[key]
281
282 def addObserver(self, callback, profile, boxname, signal="NEW_MESSAGE"):
283 """Add an observer for maildir box changes
284 @param callback: method to call when the the box is updated
285 @param boxname: name of the box to observe
286 @param signal: which signal is observed by the caller"""
287 if (profile, boxname) not in self.__observed:
288 self.__observed[(profile, boxname)] = {}
289 if signal not in self.__observed[(profile, boxname)]:
290 self.__observed[(profile, boxname)][signal] = set()
291 self.__observed[(profile, boxname)][signal].add(callback)
292
293 def removeObserver(self, callback, profile, boxname, signal="NEW_MESSAGE"):
294 """Remove an observer of maildir box changes
295 @param callback: method to remove from obervers
296 @param boxname: name of the box which was observed
297 @param signal: which signal was observed by the caller"""
298 if (profile, boxname) not in self.__observed:
299 err_msg = _(u"Trying to remove an observer for an inexistant mailbox")
300 log.error(_(u"INTERNAL ERROR: ") + err_msg)
301 raise MaildirError(err_msg)
302 if signal not in self.__observed[(profile, boxname)]:
303 err_msg = _(u"Trying to remove an inexistant observer, no observer for this signal")
304 log.error(_(u"INTERNAL ERROR: ") + err_msg)
305 raise MaildirError(err_msg)
306 if not callback in self.__observed[(profile, boxname)][signal]:
307 err_msg = _(u"Trying to remove an inexistant observer")
308 log.error(_(u"INTERNAL ERROR: ") + err_msg)
309 raise MaildirError(err_msg)
310 self.__observed[(profile, boxname)][signal].remove(callback)
311
312 def emitSignal(self, profile, boxname, signal_name):
313 """Emit the signal to observer"""
314 log.debug(u'emitSignal %s %s %s' % (profile, boxname, signal_name))
315 try:
316 for observer_cb in self.__observed[(profile, boxname)][signal_name]:
317 observer_cb()
318 except KeyError:
319 pass
320
321
322 class MailboxUser(object):
323 """This class is used to access a mailbox"""
324
325 def xmppMessage2mail(self, message):
326 """Convert the XMPP's XML message to a basic rfc2822 message
327 @param xml: domish.Element of the message
328 @return: string email"""
329 mail = email.message.Message()
330 mail['MIME-Version'] = "1.0"
331 mail['Content-Type'] = "text/plain; charset=UTF-8; format=flowed"
332 mail['Content-Transfer-Encoding'] = "8bit"
333 mail['From'] = message['from'].encode('utf-8')
334 mail['To'] = message['to'].encode('utf-8')
335 mail['Date'] = email.utils.formatdate().encode('utf-8')
336 #TODO: save thread id
337 for e in message.elements():
338 if e.name == "body":
339 mail.set_payload(e.children[0].encode('utf-8'))
340 elif e.name == "subject":
341 mail['Subject'] = e.children[0].encode('utf-8')
342 return mail.as_string()
343
344 def __init__(self, _maildir, name, observer=None, profile=C.PROF_KEY_NONE):
345 """@param _maildir: the main MaildirBox instance
346 @param name: name of the mailbox
347 @param profile: real profile (ie not a profile_key)
348 THIS OBJECT MUST NOT BE USED DIRECTLY: use MaildirBox.accessMessageBox instead"""
349 if _maildir._checkBoxReference(name, profile):
350 log.error(u"INTERNAL ERROR: MailboxUser MUST NOT be instancied directly")
351 raise MaildirError('double MailboxUser instanciation')
352 if name != "INBOX":
353 raise NotImplementedError
354 self.name = name
355 self.profile = profile
356 self.maildir = _maildir
357 profile_path = self.maildir._getProfilePath(profile)
358 full_profile_path = os.path.join(self.maildir.host.memory.getConfig('', 'local_dir'), 'maildir', profile_path)
359 if not os.path.exists(full_profile_path):
360 os.makedirs(full_profile_path, 0700)
361 mailbox_path = os.path.join(full_profile_path, MAILDIR_PATH)
362 self.mailbox_path = mailbox_path
363 self.mailbox = maildir.MaildirMailbox(mailbox_path)
364 self.observer = observer
365 self.__uid_table_update()
366
367 if observer:
368 log.debug(u"adding observer for %s (%s)" % (name, profile))
369 self.maildir.addObserver(observer, profile, name, "NEW_MESSAGE")
370
371 def __uid_table_update(self):
372 existant_id = []
373 for mess_idx in range(self.getMessageCount()):
374 #we update the uid table
375 existant_id.append(self.getId(mess_idx))
376 self.getUid(mess_idx)
377 self.maildir.cleanTable(self.name, existant_id, profile=self.profile)
378
379 def __del__(self):
380 if self.observer:
381 log.debug(u"removing observer for %s" % self.name)
382 self._maildir.removeObserver(self.observer, self.name, "NEW_MESSAGE")
383 self.maildir._removeBoxAccess(self.name, self, profile=self.profile)
384
385 def addMessage(self, message):
386 """Add a message to the box
387 @param message: XMPP XML message"""
388 self.mailbox.appendMessage(self.xmppMessage2mail(message)).addCallback(self.emitSignal, "NEW_MESSAGE")
389
390 def emitSignal(self, ignore, signal):
391 """Emit the signal to the observers"""
392 if signal == "NEW_MESSAGE":
393 self.getUid(self.getMessageCount() - 1) # XXX: we make an uid for the last message added
394 self.maildir.emitSignal(self.profile, self.name, signal)
395
396 def getId(self, mess_idx):
397 """Return the Unique ID of the message
398 @mess_idx: message index"""
399 return self.mailbox.getUidl(mess_idx)
400
401 def getUid(self, mess_idx):
402 """Return a unique interger id for the message, always ascending"""
403 mess_id = self.getId(mess_idx)
404 return self.maildir.getUid(self.name, mess_id, profile=self.profile)
405
406 def getNextUid(self):
407 return self.maildir.getNextUid(self.name, profile=self.profile)
408
409 def getNextExistingUid(self, uid):
410 return self.maildir.getNextExistingUid(self.name, uid, profile=self.profile)
411
412 def getMaxUid(self):
413 return self.maildir.getMaxUid(self.name, profile=self.profile)
414
415 def getMessageCount(self):
416 """Return number of mails present in this box"""
417 return len(self.mailbox.list)
418
419 def getMessageIdx(self, mess_idx):
420 """Return the full message
421 @mess_idx: message index"""
422 return self.mailbox.getMessage(mess_idx)
423
424 def getIdxFromUid(self, mess_uid):
425 """Return the message index from the uid
426 @param mess_uid: message unique identifier
427 @return: message index, as managed by MaildirMailbox"""
428 for mess_idx in range(self.getMessageCount()):
429 if self.getUid(mess_idx) == mess_uid:
430 return mess_idx
431 raise IndexError
432
433 def getIdxFromId(self, mess_id):
434 """Return the message index from the unique index
435 @param mess_id: message unique index as given by MaildirMailbox
436 @return: message sequence index"""
437 for mess_idx in range(self.getMessageCount()):
438 if self.mailbox.getUidl(mess_idx) == mess_id:
439 return mess_idx
440 raise IndexError
441
442 def getMessage(self, mess_idx):
443 """Return the full message
444 @param mess_idx: message index"""
445 return self.mailbox.getMessage(mess_idx)
446
447 def getMessageUid(self, mess_uid):
448 """Return the full message
449 @param mess_idx: message unique identifier"""
450 return self.mailbox.getMessage(self.getIdxFromUid(mess_uid))
451
452 def getFlags(self, mess_idx):
453 """Return the flags of the message
454 @param mess_idx: message index
455 @return: list of strings"""
456 id = self.getId(mess_idx)
457 return self.maildir.getFlags(self.name, id, profile=self.profile)
458
459 def getFlagsUid(self, mess_uid):
460 """Return the flags of the message
461 @param mess_uid: message unique identifier
462 @return: list of strings"""
463 id = self.maildir.getIdFromUid(self.name, mess_uid, profile=self.profile)
464 return self.maildir.getFlags(self.name, id, profile=self.profile)
465
466 def setFlags(self, mess_idx, flags):
467 """Change the flags of the message
468 @param mess_idx: message index
469 @param flags: list of strings
470 """
471 id = self.getId(mess_idx)
472 self.maildir.setFlags(self.name, id, flags, profile=self.profile)
473
474 def setFlagsUid(self, mess_uid, flags):
475 """Change the flags of the message
476 @param mess_uid: message unique identifier
477 @param flags: list of strings
478 """
479 id = self.maildir.getIdFromUid(self.name, mess_uid, profile=self.profile)
480 return self.maildir.setFlags(self.name, id, flags, profile=self.profile)
481
482 def getMessageIdsWithFlag(self, flag):
483 """Return ids of messages where a flag is set
484 @param flag: flag to check
485 @return: list of id (as given by MaildirMailbox)"""
486 return self.maildir.getMessageIdsWithFlag(self.name, flag, profile=self.profile)
487
488 def removeDeleted(self):
489 """Actually delete message flagged "\\Deleted"
490 Also purge the internal data of these messages
491 """
492 for mess_id in self.getMessageIdsWithFlag("\\Deleted"):
493 print ("Deleting %s" % mess_id)
494 self.mailbox.deleteMessage(self.getIdxFromId(mess_id))
495 self.mailbox = maildir.MaildirMailbox(self.mailbox_path) # We need to reparse the dir to have coherent indexing
496 self.maildir.purgeDeleted(self.name, profile=self.profile)
497
498 def emptyTrash(self):
499 """Delete everything in the .Trash dir"""
500 pass #TODO