libervia-backend/sat/plugins/plugin_misc_maildir.py

500 lines
21 KiB
Python

#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# SàT plugin for managing Maildir type mail boxes
# Copyright (C) 2011 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sat.core.i18n import D_, _
from sat.core.constants import Const as C
from sat.core.log import getLogger
log = getLogger(__name__)
import warnings
warnings.filterwarnings('ignore', 'the MimeWriter', DeprecationWarning, 'twisted') # FIXME: to be removed, see http://twistedmatrix.com/trac/ticket/4038
from twisted.mail import maildir
import email.message
import email.utils
import os
from sat.core.exceptions import ProfileUnknownError
from sat.memory.persistent import PersistentBinaryDict
PLUGIN_INFO = {
C.PI_NAME: "Maildir Plugin",
C.PI_IMPORT_NAME: "Maildir",
C.PI_TYPE: "Misc",
C.PI_PROTOCOLS: [],
C.PI_DEPENDENCIES: [],
C.PI_MAIN: "MaildirBox",
C.PI_HANDLER: "no",
C.PI_DESCRIPTION: _("""Intercept "normal" type messages, and put them in a Maildir type box""")
}
MAILDIR_PATH = "Maildir"
CATEGORY = D_("Mail Server")
NAME = D_('Block "normal" messages propagation')
# FIXME: (very) old and (very) experimental code, need a big cleaning/review or to be deprecated
class MaildirError(Exception):
pass
class MaildirBox(object):
params = """
<params>
<individual>
<category name='{category_name}' label='{category_label}'>
<param name='{name}' label='{label}' value="false" type="bool" security="4" />
</category>
</individual>
</params>
""".format(category_name=CATEGORY,
category_label=_(CATEGORY),
name=NAME,
label=_(NAME),
)
def __init__(self, host):
log.info(_("Plugin Maildir initialization"))
self.host = host
host.memory.updateParams(self.params)
self.__observed = {}
self.data = {} # list of profile spectific data. key = profile, value = PersistentBinaryDict where key=mailbox name,
# and value is a dictionnary with the following value
# - cur_idx: value of the current unique integer increment (UID)
# - message_id (as returned by MaildirMailbox): a tuple of (UID, [flag1, flag2, ...])
self.__mailboxes = {} # key: profile, value: {boxname: MailboxUser instance}
#the triggers
host.trigger.add("MessageReceived", self.messageReceivedTrigger)
def profileConnected(self, client):
"""Called on client connection, create profile data"""
profile = client.profile
self.data[profile] = PersistentBinaryDict("plugin_maildir", profile)
self.__mailboxes[profile] = {}
def dataLoaded(ignore):
if not self.data[profile]:
#the mailbox is new, we initiate the data
self.data[profile]["INBOX"] = {"cur_idx": 0}
self.data[profile].load().addCallback(dataLoaded)
def profileDisconnected(self, client):
"""Called on profile disconnection, free profile's resources"""
profile = client.profile
del self.__mailboxes[profile]
del self.data[profile]
def messageReceivedTrigger(self, client, message, post_treat):
"""This trigger catch normal message and put the in the Maildir box.
If the message is not of "normal" type, do nothing
@param message: message xmlstrem
@return: False if it's a normal message, True else"""
profile = client.profile
for e in message.elements(C.NS_CLIENT, 'body'):
mess_type = message.getAttribute('type', 'normal')
if mess_type != 'normal':
return True
self.accessMessageBox("INBOX", profile_key=profile).addMessage(message)
return not self.host.memory.getParamA(NAME, CATEGORY, profile_key=profile)
return True
def accessMessageBox(self, boxname, observer=None, profile_key=C.PROF_KEY_NONE):
"""Create and return a MailboxUser instance
@param boxname: name of the box
@param observer: method to call when a NewMessage arrive"""
profile = self.host.memory.getProfileName(profile_key)
if not profile:
raise ProfileUnknownError(profile_key)
if boxname not in self.__mailboxes[profile]:
self.__mailboxes[profile][boxname] = MailboxUser(self, boxname, observer, profile=profile)
else:
if observer:
self.addObserver(observer, profile, boxname)
return self.__mailboxes[profile][boxname]
def _getProfilePath(self, profile):
"""Return a unique path for profile's mailbox
The path must be unique, usable as a dir name, and bijectional"""
return profile.replace('/', '_').replace('..', '_') # FIXME: this is too naive to work well, must be improved
def _removeBoxAccess(self, boxname, mailboxUser, profile):
"""Remove a reference to a box
@param name: name of the box
@param mailboxUser: MailboxUser instance"""
if boxname not in self.__mailboxes:
err_msg = _("Trying to remove an mailboxUser not referenced")
log.error(_(u"INTERNAL ERROR: ") + err_msg)
raise MaildirError(err_msg)
assert self.__mailboxes[profile][boxname] == mailboxUser
del self.__mailboxes[profile][boxname]
def _checkBoxReference(self, boxname, profile):
"""Check if there is a reference on a box, and return it
@param boxname: name of the box to check
@return: MailboxUser instance or None"""
if profile in self.__mailboxes:
if boxname in self.__mailboxes[profile]:
return self.__mailboxes[profile][boxname]
def __getBoxData(self, boxname, profile):
"""Return the date of a box"""
try:
return self.data[profile][boxname] # the boxname MUST exist in the data
except KeyError:
err_msg = _("Boxname doesn't exist in internal data")
log.error(_(u"INTERNAL ERROR: ") + err_msg)
raise MaildirError(err_msg)
def getUid(self, boxname, message_id, profile):
"""Return an unique integer, always ascending, for a message
This is mainly needed for the IMAP protocol
@param boxname: name of the box where the message is
@param message_id: unique id of the message as given by MaildirMailbox
@return: Integer UID"""
box_data = self.__getBoxData(boxname, profile)
if message_id in box_data:
ret = box_data[message_id][0]
else:
box_data['cur_idx'] += 1
box_data[message_id] = [box_data['cur_idx'], []]
ret = box_data[message_id]
self.data[profile].force(boxname)
return ret
def getNextUid(self, boxname, profile):
"""Return next unique integer that will generated
This is mainly needed for the IMAP protocol
@param boxname: name of the box where the message is
@return: Integer UID"""
box_data = self.__getBoxData(boxname, profile)
return box_data['cur_idx'] + 1
def getNextExistingUid(self, boxname, uid, profile):
"""Give the next uid of existing message
@param boxname: name of the box where the message is
@param uid: uid to start from
@return: uid or None if the is no more message"""
box_data = self.__getBoxData(boxname, profile)
idx = uid + 1
while self.getIdFromUid(boxname, idx, profile) is None: # TODO: this is highly inefficient because getIdfromUid is inefficient, fix this
idx += 1
if idx > box_data['cur_idx']:
return None
return idx
def getMaxUid(self, boxname, profile):
"""Give the max existing uid
@param boxname: name of the box where the message is
@return: uid"""
box_data = self.__getBoxData(boxname, profile)
return box_data['cur_idx']
def getIdFromUid(self, boxname, message_uid, profile):
"""Return the message unique id from it's integer UID
@param boxname: name of the box where the message is
@param message_uid: unique integer identifier
@return: unique id of the message as given by MaildirMailbox or None if not found"""
box_data = self.__getBoxData(boxname, profile)
for message_id in box_data.keys(): # TODO: this is highly inefficient on big mailbox, must be replaced in the future
if message_id == 'cur_idx':
continue
if box_data[message_id][0] == message_uid:
return message_id
return None
def getFlags(self, boxname, mess_id, profile):
"""Return the messages flags
@param boxname: name of the box where the message is
@param message_idx: message id as given by MaildirMailbox
@return: list of strings"""
box_data = self.__getBoxData(boxname, profile)
if mess_id not in box_data:
raise MaildirError("Trying to get flags from an unexisting message")
return box_data[mess_id][1]
def setFlags(self, boxname, mess_id, flags, profile):
"""Change the flags of the message
@param boxname: name of the box where the message is
@param message_idx: message id as given by MaildirMailbox
@param flags: list of strings
"""
box_data = self.__getBoxData(boxname, profile)
assert(type(flags) == list)
flags = [flag.upper() for flag in flags] # we store every flag UPPERCASE
if mess_id not in box_data:
raise MaildirError("Trying to set flags for an unexisting message")
box_data[mess_id][1] = flags
self.data[profile].force(boxname)
def getMessageIdsWithFlag(self, boxname, flag, profile):
"""Return ids of messages where a flag is set
@param boxname: name of the box where the message is
@param flag: flag to check
@return: list of id (as given by MaildirMailbox)"""
box_data = self.__getBoxData(boxname, profile)
assert(isinstance(flag, basestring))
flag = flag.upper()
result = []
for key in box_data:
if key == 'cur_idx':
continue
if flag in box_data[key][1]:
result.append(key)
return result
def purgeDeleted(self, boxname, profile):
"""Remove data for messages with flag "\\Deleted"
@param boxname: name of the box where the message is
"""
box_data = self.__getBoxData(boxname, profile)
for mess_id in self.getMessageIdsWithFlag(boxname, "\\Deleted", profile):
del(box_data[mess_id])
self.data[profile].force(boxname)
def cleanTable(self, boxname, existant_id, profile):
"""Remove mails which no longuer exist from the table
@param boxname: name of the box to clean
@param existant_id: list of id which actually exist"""
box_data = self.__getBoxData(boxname, profile)
to_remove = []
for key in box_data:
if key not in existant_id and key != "cur_idx":
to_remove.append(key)
for key in to_remove:
del box_data[key]
def addObserver(self, callback, profile, boxname, signal="NEW_MESSAGE"):
"""Add an observer for maildir box changes
@param callback: method to call when the the box is updated
@param boxname: name of the box to observe
@param signal: which signal is observed by the caller"""
if (profile, boxname) not in self.__observed:
self.__observed[(profile, boxname)] = {}
if signal not in self.__observed[(profile, boxname)]:
self.__observed[(profile, boxname)][signal] = set()
self.__observed[(profile, boxname)][signal].add(callback)
def removeObserver(self, callback, profile, boxname, signal="NEW_MESSAGE"):
"""Remove an observer of maildir box changes
@param callback: method to remove from obervers
@param boxname: name of the box which was observed
@param signal: which signal was observed by the caller"""
if (profile, boxname) not in self.__observed:
err_msg = _(u"Trying to remove an observer for an inexistant mailbox")
log.error(_(u"INTERNAL ERROR: ") + err_msg)
raise MaildirError(err_msg)
if signal not in self.__observed[(profile, boxname)]:
err_msg = _(u"Trying to remove an inexistant observer, no observer for this signal")
log.error(_(u"INTERNAL ERROR: ") + err_msg)
raise MaildirError(err_msg)
if not callback in self.__observed[(profile, boxname)][signal]:
err_msg = _(u"Trying to remove an inexistant observer")
log.error(_(u"INTERNAL ERROR: ") + err_msg)
raise MaildirError(err_msg)
self.__observed[(profile, boxname)][signal].remove(callback)
def emitSignal(self, profile, boxname, signal_name):
"""Emit the signal to observer"""
log.debug(u'emitSignal %s %s %s' % (profile, boxname, signal_name))
try:
for observer_cb in self.__observed[(profile, boxname)][signal_name]:
observer_cb()
except KeyError:
pass
class MailboxUser(object):
"""This class is used to access a mailbox"""
def xmppMessage2mail(self, message):
"""Convert the XMPP's XML message to a basic rfc2822 message
@param xml: domish.Element of the message
@return: string email"""
mail = email.message.Message()
mail['MIME-Version'] = "1.0"
mail['Content-Type'] = "text/plain; charset=UTF-8; format=flowed"
mail['Content-Transfer-Encoding'] = "8bit"
mail['From'] = message['from'].encode('utf-8')
mail['To'] = message['to'].encode('utf-8')
mail['Date'] = email.utils.formatdate().encode('utf-8')
#TODO: save thread id
for e in message.elements():
if e.name == "body":
mail.set_payload(e.children[0].encode('utf-8'))
elif e.name == "subject":
mail['Subject'] = e.children[0].encode('utf-8')
return mail.as_string()
def __init__(self, _maildir, name, observer=None, profile=C.PROF_KEY_NONE):
"""@param _maildir: the main MaildirBox instance
@param name: name of the mailbox
@param profile: real profile (ie not a profile_key)
THIS OBJECT MUST NOT BE USED DIRECTLY: use MaildirBox.accessMessageBox instead"""
if _maildir._checkBoxReference(name, profile):
log.error(u"INTERNAL ERROR: MailboxUser MUST NOT be instancied directly")
raise MaildirError('double MailboxUser instanciation')
if name != "INBOX":
raise NotImplementedError
self.name = name
self.profile = profile
self.maildir = _maildir
profile_path = self.maildir._getProfilePath(profile)
full_profile_path = os.path.join(self.maildir.host.memory.getConfig('', 'local_dir'), 'maildir', profile_path)
if not os.path.exists(full_profile_path):
os.makedirs(full_profile_path, 0700)
mailbox_path = os.path.join(full_profile_path, MAILDIR_PATH)
self.mailbox_path = mailbox_path
self.mailbox = maildir.MaildirMailbox(mailbox_path)
self.observer = observer
self.__uid_table_update()
if observer:
log.debug(u"adding observer for %s (%s)" % (name, profile))
self.maildir.addObserver(observer, profile, name, "NEW_MESSAGE")
def __uid_table_update(self):
existant_id = []
for mess_idx in range(self.getMessageCount()):
#we update the uid table
existant_id.append(self.getId(mess_idx))
self.getUid(mess_idx)
self.maildir.cleanTable(self.name, existant_id, profile=self.profile)
def __del__(self):
if self.observer:
log.debug(u"removing observer for %s" % self.name)
self._maildir.removeObserver(self.observer, self.name, "NEW_MESSAGE")
self.maildir._removeBoxAccess(self.name, self, profile=self.profile)
def addMessage(self, message):
"""Add a message to the box
@param message: XMPP XML message"""
self.mailbox.appendMessage(self.xmppMessage2mail(message)).addCallback(self.emitSignal, "NEW_MESSAGE")
def emitSignal(self, ignore, signal):
"""Emit the signal to the observers"""
if signal == "NEW_MESSAGE":
self.getUid(self.getMessageCount() - 1) # XXX: we make an uid for the last message added
self.maildir.emitSignal(self.profile, self.name, signal)
def getId(self, mess_idx):
"""Return the Unique ID of the message
@mess_idx: message index"""
return self.mailbox.getUidl(mess_idx)
def getUid(self, mess_idx):
"""Return a unique interger id for the message, always ascending"""
mess_id = self.getId(mess_idx)
return self.maildir.getUid(self.name, mess_id, profile=self.profile)
def getNextUid(self):
return self.maildir.getNextUid(self.name, profile=self.profile)
def getNextExistingUid(self, uid):
return self.maildir.getNextExistingUid(self.name, uid, profile=self.profile)
def getMaxUid(self):
return self.maildir.getMaxUid(self.name, profile=self.profile)
def getMessageCount(self):
"""Return number of mails present in this box"""
return len(self.mailbox.list)
def getMessageIdx(self, mess_idx):
"""Return the full message
@mess_idx: message index"""
return self.mailbox.getMessage(mess_idx)
def getIdxFromUid(self, mess_uid):
"""Return the message index from the uid
@param mess_uid: message unique identifier
@return: message index, as managed by MaildirMailbox"""
for mess_idx in range(self.getMessageCount()):
if self.getUid(mess_idx) == mess_uid:
return mess_idx
raise IndexError
def getIdxFromId(self, mess_id):
"""Return the message index from the unique index
@param mess_id: message unique index as given by MaildirMailbox
@return: message sequence index"""
for mess_idx in range(self.getMessageCount()):
if self.mailbox.getUidl(mess_idx) == mess_id:
return mess_idx
raise IndexError
def getMessage(self, mess_idx):
"""Return the full message
@param mess_idx: message index"""
return self.mailbox.getMessage(mess_idx)
def getMessageUid(self, mess_uid):
"""Return the full message
@param mess_idx: message unique identifier"""
return self.mailbox.getMessage(self.getIdxFromUid(mess_uid))
def getFlags(self, mess_idx):
"""Return the flags of the message
@param mess_idx: message index
@return: list of strings"""
id = self.getId(mess_idx)
return self.maildir.getFlags(self.name, id, profile=self.profile)
def getFlagsUid(self, mess_uid):
"""Return the flags of the message
@param mess_uid: message unique identifier
@return: list of strings"""
id = self.maildir.getIdFromUid(self.name, mess_uid, profile=self.profile)
return self.maildir.getFlags(self.name, id, profile=self.profile)
def setFlags(self, mess_idx, flags):
"""Change the flags of the message
@param mess_idx: message index
@param flags: list of strings
"""
id = self.getId(mess_idx)
self.maildir.setFlags(self.name, id, flags, profile=self.profile)
def setFlagsUid(self, mess_uid, flags):
"""Change the flags of the message
@param mess_uid: message unique identifier
@param flags: list of strings
"""
id = self.maildir.getIdFromUid(self.name, mess_uid, profile=self.profile)
return self.maildir.setFlags(self.name, id, flags, profile=self.profile)
def getMessageIdsWithFlag(self, flag):
"""Return ids of messages where a flag is set
@param flag: flag to check
@return: list of id (as given by MaildirMailbox)"""
return self.maildir.getMessageIdsWithFlag(self.name, flag, profile=self.profile)
def removeDeleted(self):
"""Actually delete message flagged "\\Deleted"
Also purge the internal data of these messages
"""
for mess_id in self.getMessageIdsWithFlag("\\Deleted"):
print ("Deleting %s" % mess_id)
self.mailbox.deleteMessage(self.getIdxFromId(mess_id))
self.mailbox = maildir.MaildirMailbox(self.mailbox_path) # We need to reparse the dir to have coherent indexing
self.maildir.purgeDeleted(self.name, profile=self.profile)
def emptyTrash(self):
"""Delete everything in the .Trash dir"""
pass #TODO