First working version of the new 'message filters' plugin. Currently, only the raw edition mode is available. I also include my own MANAGESIEVE client implementation.

see #139
This commit is contained in:
Antoine Nguyen
2011-05-18 14:50:05 +02:00
parent f0fe73e8b0
commit a15d879109
30 changed files with 1395 additions and 75 deletions
+6
View File
@@ -0,0 +1,6 @@
# coding: utf-8
from modoboa.lib import parameters
from django.utils.translation import ugettext as _
parameters.register_admin("SECRET_KEY", type="string", deflt="abcdefghijklmnop",
help=_("Key used to encrypt/decrypt passwords"))
+24
View File
@@ -0,0 +1,24 @@
# coding: utf-8
from Crypto.Cipher import AES
import base64
from modoboa.lib import parameters
def encrypt(clear):
obj = AES.new(parameters.get_admin("SECRET_KEY"),
AES.MODE_ECB)
if len(clear) % AES.block_size:
clear += "\0" * (AES.block_size - len(clear) % AES.block_size)
ciph = obj.encrypt(clear)
ciph = base64.b64encode(ciph)
return ciph
def decrypt(ciph):
obj = AES.new(parameters.get_admin("SECRET_KEY"), AES.MODE_ECB)
ciph = base64.b64decode(ciph)
clear = obj.decrypt(ciph)
return clear.rstrip('\0')
def get_password(request):
return decrypt(request.session["password"])
+4
View File
@@ -8,6 +8,7 @@ from django.utils.translation import ugettext as _
from modoboa.lib import events
import modoboa.admin
from forms import LoginForm
from modoboa.auth.lib import *
def dologin(request):
error = None
@@ -19,6 +20,9 @@ def dologin(request):
if user.is_active:
login(request, user)
if request.user.id != 1:
request.session["password"] = encrypt(request.POST["password"])
events.raiseEvent("UserLogin", request=request,
username=request.POST["username"],
password=request.POST["password"])
View File
+63
View File
@@ -0,0 +1,63 @@
# coding: utf-8
import base64
import hashlib
import binascii
import re
import random
class DigestMD5(object):
def __init__(self, challenge, digesturi):
self.__digesturi = digesturi
self.__challenge = challenge
self.__params = {}
pexpr = re.compile('(\w+)="(.+)"')
for elt in base64.b64decode(challenge).split(","):
m = pexpr.match(elt)
if m is None:
continue
self.__params[m.group(1)] = m.group(2)
def __make_cnonce(self):
ret = ""
for i in xrange(12):
ret += chr(random.randint(0, 0xff))
return base64.b64encode(ret)
def __digest(self, value):
return hashlib.md5(value).digest()
def __hexdigest(self, value):
return binascii.hexlify(hashlib.md5(value).digest())
def __make_response(self, username, password, check=False):
a1 = "%s:%s:%s" % (self.__digest("%s:%s:%s" % (username, self.realm, password)),
self.__params["nonce"], self.cnonce)
if check:
a2 = ":%s" % self.__digesturi
else:
a2 = "AUTHENTICATE:%s" % self.__digesturi
resp = "%s:%s:00000001:%s:auth:%s" \
% (self.__hexdigest(a1), self.__params["nonce"],
self.cnonce, self.__hexdigest(a2))
return self.__hexdigest(resp)
def response(self, username, password):
self.realm = self.__params["realm"] if self.__params.has_key("realm") else ""
self.cnonce = self.__make_cnonce()
respvalue = self.__make_response(username, password)
dgres = 'username="%s",%snonce="%s",cnonce="%s",nc=00000001,qop=auth,' \
'digest-uri="%s",response=%s' \
% (username, ('realm="%s",' % self.realm) if len(self.realm) else "",
self.__params["nonce"], self.cnonce, self.__digesturi, respvalue)
return base64.b64encode(dgres)
def check_last_challenge(self, username, password, value):
challenge = base64.b64decode(value.strip('"'))
return challenge == \
("rspauth=%s" % self.__make_response(username, password, True))
+10
View File
@@ -0,0 +1,10 @@
from django import forms
from django.utils.translation import ugettext as _
from modoboa.admin.templatetags.admin_extras import gender
class FiltersSetForm(forms.Form):
name = forms.CharField()
active = forms.BooleanField(label=gender("Active", "m"), required=False,
initial=False,
help_text=_("Check to activate this filters set"))
+65
View File
@@ -0,0 +1,65 @@
# coding: utf-8
from django.utils.translation import ugettext as _
from managesieve import ManageSieveClient, Error
from modoboa.lib import ConnectionsManager, ConnectionError, parameters
class SieveClientError(Exception):
pass
class SieveClient(object):
__metaclass__ = ConnectionsManager
def __init__(self, user=None, password=None):
ret, msg = self.login(user, password)
if not ret:
raise ConnectionError(msg)
def login(self, user, password):
self.msc = ManageSieveClient(parameters.get_admin("SERVER"),
int(parameters.get_admin("PORT")),
debug=False)
use_starttls = True if parameters.get_admin("STARTTLS") == "yes" else False
authmech = parameters.get_admin("AUTHENTICATION_MECH")
if authmech == "AUTO":
authmech = None
if not self.msc.connect(user, password, use_starttls, authmech):
return False, _("Connection to MANAGESIEVE server failed, check your configuration")
return True, None
def refresh(self, user, password):
try:
self.msc.capability()
except Error, e:
pass
else:
return
ret, msg = self.login(user, password)
if not ret:
raise ConnectionError(msg)
def listscripts(self):
return self.msc.listscripts()
def getscript(self, name):
content = self.msc.getscript(name)
if content is None:
raise SieveClientError(self.msc.errmsg)
return content
def pushscript(self, name, content, active=False):
if not self.msc.havespace(name, len(content)):
error = "%s (%s)" % (_("Not enough space on server"), self.msc.errmsg)
raise SieveClientError(error)
if not self.msc.putscript(name, content):
raise SieveClientError(self.msc.errmsg)
if active and not self.msc.setactive(name):
raise SieveClientError(self.msc.errmsg)
def deletescript(self, name):
if not self.msc.deletescript(name):
raise SieveClientError(self.msc.errmsg)
def activatescript(self, name):
if not self.msc.setactive(name):
raise SieveClientError(self.msc.errmsg)
+51
View File
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse
from django.conf.urls.defaults import include
from modoboa.lib import events, parameters, static_url
from managesieve import SUPPORTED_AUTH_MECHS
def init():
events.register("UserMenuDisplay", menu)
parameters.register_admin("SERVER", type="string",
deflt="127.0.0.1",
help=_("Address of your MANAGESIEVE server"))
parameters.register_admin("PORT", type="int", deflt="2000",
help=_("Listening port of your MANAGESIEVE server"))
parameters.register_admin("STARTTLS", type="list_yesno", deflt="no",
help=_("Use the STARTTLS extension"))
values = [('AUTO', 'auto')]
for m in SUPPORTED_AUTH_MECHS:
values += [(m, m.lower())]
parameters.register_admin("AUTHENTICATION_MECH", type="list", deflt="auto",
values=values,
help=_("Prefered authentication mechanism"))
def destroy():
events.unregister("UserMenuDisplay", menu)
parameters.unregister_app("sievefilters")
def infos():
return {
"name" : "Sieve filters",
"version" : "1.0",
"description" : _("Plugin to easily create server-side filters")
}
def urls(prefix):
return (r'^%ssfilters/' % prefix,
include('modoboa.extensions.sievefilters.urls'))
def menu(**kwargs):
import views
if kwargs["target"] != "options_menu":
return []
return [
{"name" : "sievefilters",
"label" : _("Message filters"),
"url" : reverse(views.index),
"img" : static_url("pics/filters.png")}
]
+620
View File
@@ -0,0 +1,620 @@
#!/usr/bin/env python
# coding: utf-8
"""MANAGESIEVE client.
A protocol for securely managing Sieve scripts on a remote server.
This protocol allows a user to have multiple scripts, and also alerts
a user to syntactically flawed scripts.
Implementation based on <draft-martin-managesieve-12>.
"""
import sys, os
import socket
import re
import base64
import ssl
from digest_md5 import DigestMD5
CRLF = '\r\n'
KNOWN_CAPABILITIES = ["IMPLEMENTATION", "SASL", "SIEVE",
"STARTTLS", "NOTIFY", "LANGUAGE",
"RENAME", "NOOP", "UNAUTHENTICATE"]
SUPPORTED_AUTH_MECHS = ["DIGEST-MD5", "PLAIN", "LOGIN"]
class Error(Exception):
pass
class Response(Exception):
def __init__(self, code, data):
self.code = code
self.data = data
def __str__(self):
return "%s %s" % (self.code, self.data)
def authentication_required(meth):
"""Simple class method decorator.
Checks if the client is currently connected.
:param meth: the original called method
"""
def check(cls, *args, **kwargs):
if cls.authenticated:
return meth(cls, *args, **kwargs)
raise Error("Authentication required")
return check
class ManageSieveClient:
read_size = 4096
read_timeout = 5
def __init__(self, srvaddr, srvport=2000, debug=False):
self.srvaddr = srvaddr
self.srvport = srvport
self.__debug = debug
self.sock = None
self.__read_buffer = ""
self.authenticated = False
self.errcode = None
self.__capabilities = {}
self.__respcode_expr = re.compile("(OK|NO|BYE)\s*(.+)?")
self.__error_expr = re.compile('(\(\w+\))?\s*(".+")')
self.__size_expr = re.compile("\{(\d+)\+?\}")
self.__active_expr = re.compile("ACTIVE", re.IGNORECASE)
def __del__(self):
if self.sock is not None:
self.sock.close()
self.sock = None
def __dprint(self, message):
if not self.__debug:
return
print "DEBUG: %s" % message
def __read_block(self, size):
"""Read a block of 'size' bytes from the server.
An internal buffer is used to read data from the server. If
enough data is available from it, we return that data.
Eventually, we try to grab the missing part from the server
for ManageSieveClient.read_timeout seconds. If no data can be
retrieved, it is considered as a fatal error and an 'Error'
exception is raised.
:param size: number of bytes to read
:rtype: string
:returns: the read block (can be empty)
"""
buf = ""
if len(self.__read_buffer):
limit = size if size <= len(self.__read_buffer) else len(self.__read_buffer)
buf = self.__read_buffer[:limit]
self.__read_buffer = self.__read_buffer[limit:]
size -= limit
if not size:
return buf
try:
buf += self.sock.recv(size)
except (socket.timeout, ssl.SSLError), e:
raise Error("Failed to read %d bytes from the server" % size)
return buf
def __read_line(self):
"""Read one line from the server.
An internal buffer is used to read data from the server
(blocks of ManageSieveClient.read_size bytes). If the buffer
is not empty, we try to find an entire line to return.
If we failed, we try to read new content from the server for
ManageSieveClient.read_timeout seconds. If no data can be
retrieved, it is considered as a fatal error and an 'Error'
exception is raised.
:rtype: string
:return: the read line
"""
ret = ""
while True:
try:
pos = self.__read_buffer.index(CRLF)
ret = self.__read_buffer[0:pos]
self.__read_buffer = self.__read_buffer[pos+len(CRLF):]
break
except ValueError:
pass
try:
nval = self.sock.recv(self.read_size)
if not len(nval):
break
self.__read_buffer += nval
except (socket.timeout, ssl.SSLError):
raise Error("Failed to read data from the server")
if len(ret):
m = self.__respcode_expr.match(ret)
if m:
if m.group(1) == "BYE":
raise Error("Connection closed by server")
if m.group(1) == "NO":
self.__parse_error(m.group(2))
raise Response(m.group(1), m.group(2))
return ret
def __read_response(self, nblines=-1):
"""Read a response from the server.
In the usual case, we read lines until we find one that looks
like a response (OK|NO|BYE\s*(.+)?).
If *nblines* > 0, we read excactly nblines before returning.
:param nblines: number of lines to read (default : -1)
:rtype: tuple
:return: a tuple of the form (code, data, response). If
nblines is provided, code and data can be equal to None.
"""
resp, code, data = ("", None, None)
cpt = 0
while True:
try:
line = self.__read_line()
except Response, inst:
code = inst.code
data = inst.data
break
if not len(line):
break
resp += line + CRLF
cpt += 1
if nblines != -1 and cpt == nblines:
break
return (code, data, resp)
def __prepare_args(self, args):
"""Format command arguments before sending them.
Command arguments of type string must be quoted, the only
exception concerns size indication (of the form {\d\+?}).
:param args: list of arguments
:return: a list for transformed arguments
"""
ret = []
for a in args:
if type(a) in [str, unicode] and self.__size_expr.match(a) is None:
ret += ['"%s"' % a]
continue
ret += ["%s" % str(a)]
return ret
def __send_command(self, name, args=[], withcontent=False, extralines=[], nblines=-1):
"""Send a command to the server.
If args is not empty, we concatenate the given command with
the content of this list. If extralines is not empty, they are
sent one by one to the server. (CLRF are automatically appended to them)
We wait for a response just after the command has been sent.
:param name: the command to sent
:param args: a list of arguments for this command
:param withcontent: tells the function to return the server's response or not
:param extralines: a list of extra lines to sent after the command
:param nblines: the number of response lines to read (all by default)
:returns: a tuple of the form (code, data[, response])
"""
tosend = name
if len(args):
tosend += " " + " ".join(self.__prepare_args(args))
self.__dprint("Command: %s" % tosend)
self.sock.send("%s%s" % (tosend, CRLF))
for l in extralines:
self.sock.send("%s%s" % (l, CRLF))
code, data, content = self.__read_response(nblines)
if withcontent:
return (code, data, content)
return (code, data)
def __get_capabilities(self):
code, data, capabilities = self.__read_response()
if code == "NO":
return False
for l in capabilities.splitlines()[0:-2]:
parts = l.split(None, 1)
cname = parts[0].strip('"')
if not cname in KNOWN_CAPABILITIES:
continue
self.__capabilities[cname] = \
parts[1].strip('"') if len(parts) > 1 else None
return True
def __parse_error(self, text):
"""Parse an error received from the server.
if text corresponds to a size indication, we grab the
remaining content from the server.
Otherwise, we try to match an error of the form \(\w+\)?\s*".+"
On succes, the two public members errcode and errmsg are
filled with the parsing results.
:param text: the response to parse
"""
m = self.__size_expr.match(text)
if m is not None:
self.errcode = ""
self.errmsg = self.__read_block(int(m.group(1)) + 2)
return
m = self.__error_expr.match(text)
if m is None:
raise Error("Bad error message")
if m.group(1) is not None:
self.errcode = m.group(1).strip("()")
else:
self.errcode = ""
self.errmsg = m.group(2).strip('"')
def _plain_authentication(self, login, password):
"""SASL PLAIN authentication
:param login: username
:param password: clear password
:return: True on success, False otherwise.
"""
params = base64.b64encode('\0' + '\0'.join([login, password]))
code, data = self.__send_command("AUTHENTICATE", ["PLAIN", params])
if code == "OK":
return True
return False
def _login_authentication(self, login, password):
"""SASL LOGIN authentication
:param login: username
:param password: clear password
:return: True on success, False otherwise.
"""
extralines = ['"%s"' % base64.b64encode(login),
'"%s"' % base64.b64encode(password)]
code, data = self.__send_command("AUTHENTICATE", ["LOGIN"],
extralines=extralines)
if code == "OK":
return True
return False
def _digest_md5_authentication(self, login, password):
"""SASL DIGEST-MD5 authentication
:param login: username
:param password: clear password
:return: True on success, False otherwise.
"""
code, data, challenge = \
self.__send_command("AUTHENTICATE", ["DIGEST-MD5"],
withcontent=True, nblines=1)
dmd5 = DigestMD5(challenge, "sieve/%s" % self.srvaddr)
code, data, challenge = \
self.__send_command('"%s"' % dmd5.response(login, password),
withcontent=True, nblines=1)
if not len(challenge):
return False
if not dmd5.check_last_challenge(login, password, challenge):
self.errmsg = "Bad challenge received from server"
return False
code, data = self.__send_command('""')
if code == "OK":
return True
return False
def __authenticate(self, login, password, authmech=None):
"""AUTHENTICATE command
Actually, it is just a wrapper to the real commands (one by
mechanism). We try all supported mechanisms (from the
strongest to the weakest) until we find one supported by the
server.
Then we try to authenticate (only once).
:param login: username
:param password: clear password
:param authmech: prefered authentication mechanism
:return: True on success, False otherwise
"""
if not self.__capabilities.has_key("SASL"):
raise Error("SASL not supported by the server")
srv_mechanisms = self.get_sasl_mechanisms()
if authmech is None or authmech not in SUPPORTED_AUTH_MECHS:
mech_list = SUPPORTED_AUTH_MECHS
else:
mech_list = [authmech]
for mech in mech_list:
if not mech in srv_mechanisms:
continue
mech = mech.lower()
mech = re.sub("-", "_", mech)
if getattr(self, "_%s_authentication" % mech.lower())(login, password):
self.authenticated = True
return True
return False
self.errmsg = "No suitable mechanism found"
return False
def __starttls(self, keyfile=None, certfile=None):
"""STARTTLS command
See MANAGESIEVE specifications, section 2.2.
:param keyfile: an eventual private key to use
:param certfile: an eventual certificate to use
:rtype: boolean
"""
if not self.has_tls_support():
raise Error("STARTTLS not supported by the server")
code, data = self.__send_command("STARTTLS")
if code != "OK":
return False
try:
nsock = ssl.wrap_socket(self.sock, keyfile, certfile)
except ssl.SSLError, e:
raise Error("SSL error: %s" % str(e))
self.sock = nsock
self.__capabilities = {}
self.__get_capabilities()
return True
def get_implementation(self):
"""Returns the IMPLEMENTATION value.
It is read from server capabilities. (see the CAPABILITY
command)
:rtype: string
"""
return self.__capabilities["IMPLEMENTATION"]
def get_sasl_mechanisms(self):
"""Returns the supported authentication mechanisms.
They're read from server capabilities. (see the CAPABILITY
command)
:rtype: list of string
"""
return self.__capabilities["SASL"].split()
def has_tls_support(self):
"""Tells if the server has STARTTLS support or not.
It is read from server capabilities. (see the CAPABILITY
command)
:rtype: boolean
"""
return self.__capabilities.has_key("STARTTLS")
def get_sieve_capabilities(self):
"""Returns the SIEVE extensions supported by the server.
They're read from server capabilities. (see the CAPABILITY
command)
:rtype: string
"""
if type(self.__capabilities["SIEVE"]) == str:
self.__capabilities["SIEVE"] = self.__capabilities["SIEVE"].split()
return self.__capabilities["SIEVE"]
def connect(self, login, password, starttls=False, authmech=None):
"""Establish a connection with the server.
This function must be used. It read the server capabilities
and wraps calls to STARTTLS and AUTHENTICATE commands.
:param login: username
:param password: clear password
:param starttls: use a TLS connection or not
:param authmech: prefered authenticate mechanism
:rtype: boolean
"""
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.srvaddr, self.srvport))
self.sock.settimeout(ManageSieveClient.read_timeout)
except socket.error, msg:
raise Error("Connection to server failed: %s" % str(msg))
if not self.__get_capabilities():
raise Error("Failed to read capabilities from server")
if starttls and not self.__starttls():
return False
if self.__authenticate(login, password, authmech):
return True
return False
def logout(self):
"""Disconnect from the server
See MANAGESIEVE specifications, section 2.3
"""
self.__send_command("LOGOUT")
def capability(self):
"""Ask server capabilities.
See MANAGESIEVE specifications, section 2.4 This command does
not affect capabilities recorded by this client.
:rtype: string
"""
code, data, capabilities = \
self.__send_command("CAPABILITY", withcontent=True)
if code == "OK":
return capabilities
return None
@authentication_required
def havespace(self, scriptname, scriptsize):
"""Ask for available space.
See MANAGESIEVE specifications, section 2.5
:param scriptname: script's name
:param scriptsize: script's size
:rtype: boolean
"""
code, data = self.__send_command("HAVESPACE", [scriptname, scriptsize])
if code == "OK":
return True
return False
@authentication_required
def listscripts(self):
"""List available scripts.
See MANAGESIEVE specifications, section 2.7
:returns: a 2-uple (active script, [script1, ...])
"""
code, data, listing = self.__send_command("LISTSCRIPTS", withcontent=True)
if code == "NO":
return None
ret = []
active_script = None
for l in listing.splitlines():
if self.__size_expr.match(l):
continue
m = re.match('"([^"]+)"\s*(.+)', l)
if m is None:
ret += [l.strip('"')]
else:
if self.__active_expr.match(m.group(2)):
active_script = m.group(1)
else:
ret += [m.group(1)]
self.__dprint(ret)
return (active_script, ret)
@authentication_required
def getscript(self, name):
"""Download a script from the server
See MANAGESIEVE specifications, section 2.9
:param name: script's name
:rtype: string
:returns: the script's content on succes, None otherwise
"""
code, data, content = self.__send_command("GETSCRIPT", [name], withcontent=True)
if code == "OK":
lines = content.splitlines()
if self.__size_expr.match(lines[0]) is not None:
lines = lines[1:]
return "\n".join(lines)
return None
@authentication_required
def putscript(self, name, content):
"""Upload a script to the server
See MANAGESIEVE specifications, section 2.6
:param name: script's name
:param content: script's content
:rtype: boolean
"""
content = "{%d+}%s%s" % (len(content), CRLF, content)
code, data = \
self.__send_command("PUTSCRIPT", [name, content])
if code == "OK":
return True
return False
@authentication_required
def deletescript(self, name):
"""Delete a script from the server
See MANAGESIEVE specifications, section 2.10
:param name: script's name
:rtype: boolean
"""
code, data = self.__send_command("DELETESCRIPT", [name])
if code == "OK":
return True
return False
@authentication_required
def renamescript(self, oldname, newname):
"""Rename a script on the server
See MANAGESIEVE specifications, section 2.11.1
As this command is optional, we emulate it if the server does
not support it.
:param oldname: current script's name
:param newname: new script's name
:rtype: boolean
"""
if self.__capabilities.has_key("RENAMESCRIPT"):
code, data = self.__send_command("RENAMESCRIPT", [oldname, newname])
if code == "OK":
return True
return False
(active_script, scripts) = self.listscripts()
if scripts == None or not oldname in scripts:
self.errmsg = "Old script does not exist"
return False
if newname in scripts:
self.errmsg = "New script already exists"
return False
oldscript = self.getscript(oldname)
if oldscript is None:
return False
if not self.putscript(newname, oldscript):
return False
if active_script == oldname:
if not self.setactive(newname):
return False
if not self.deletescript(oldname):
return False
return True
@authentication_required
def setactive(self, scriptname):
"""Define the active script
See MANAGESIEVE specifications, section 2.8
If scriptname is empty, the current active script is disabled,
ie. there will be no active script anymore.
:param scriptname: script's name
:rtype: boolean
"""
code, data = self.__send_command("SETACTIVE", [scriptname])
if code == "OK":
return True
return False
@@ -0,0 +1,72 @@
#sets_list {
position: absolute;
border: 1px solid #CCC;
left: 10px;
top: 0px;
bottom: 10px;
width: 160px;
}
#set_content {
position: absolute;
left: 180px;
right: 10px;
top: 0px;
bottom: 10px;
padding: 0;
}
#sets_list ul {
list-style: none;
padding: 5px 0 0 5px;
margin: 0;
}
#sets_list li {
margin: 2px 0;
}
#sets_list a, #sets_list a:visited {
color: black;
text-decoration: none;
}
#sets_list a:hover {
color: #6AB0C5;
text-decoration: none;
}
#sets_list li.selected {
background-color: #FFFF77;
}
#sets_list img {
float: right;
}
#set_content form {
height: 100%;
}
#set_content textarea {
margin: 0;
width: 100%;
height: 100%;
}
#id_content {
width: 95% !important;
height: 270px;
}
#submit_container {
width: 100%;
position: absolute;
bottom: 5px;
text-align: center;
}
#error {
white-space: pre-line;
}
@@ -0,0 +1,121 @@
{% extends "nlayout.html" %}
{% load i18n libextras sfilters_tags %}
{% block html_head %}
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}sievefilters/css/style.css" />
<script type="text/javascript">
var curscript = null;
function loadscript(evt) {
var params = "name=" + this.get("href");
curscript = this.get("href");
evt.stop();
$$("#sets_list li").removeClass("selected");
this.getParent().addClass("selected");
new Request.JSON({
url: "{% url extensions.sievefilters.views.getscript %}",
onSuccess: function(response) {
if (response.status == "ok") {
$("set_content").set("html", response.content);
}
}
}).get(params);
}
function check_prereqs(evt, msg) {
evt.stop();
if (!curscript) {
return false;
}
if (!confirm(msg)) {
return false;
}
return true;
}
function send_command(cmd) {
new Request.JSON({
url: cmd,
data: "name=" + curscript,
onSuccess: function(response) {
if (response.status == "ko") {
infobox.calcsize(300);
infobox.error(response.respmsg);
} else {
if (!response.norefresh) {
location.reload();
}
infobox.calcsize();
infobox.info(response.respmsg);
infobox.hide(2);
}
}
}).get();
}
function savescript(evt) {
if (!check_prereqs(evt, gettext("Save changes?"))) {
return;
}
new Request.JSON({
url: $("feditor").get("action"),
method: $("feditor").get("method"),
data: $("feditor").toQueryString(),
onSuccess: function(response) {
if (response.status == "ko") {
infobox.calcsize(300);
infobox.error(response.respmsg);
} else {
infobox.calcsize();
infobox.info(response.respmsg);
infobox.hide(2);
}
}
}).send();
}
function deletescript(evt) {
if (!check_prereqs(evt, gettext("Delete filters set?"))) {
return;
}
send_command("{% url extensions.sievefilters.views.delete_filters_set %}");
}
function activatescript(evt) {
if (!check_prereqs(evt, gettext("Activate this filters set?"))) {
return;
}
send_command("{% url extensions.sievefilters.views.activate_filters_set %}");
}
$(document).addEvent("domready", function() {
$$("a[name=loadscript]").addEvent("click", loadscript);
$$("a[name=savescript]").addEvent("click", savescript);
$$("a[name=deletescript]").addEvent("click", deletescript);
$$("a[name=activatescript]").addEvent("click", activatescript);
});
</script>
{% endblock %}
{% block middletitle %}{% trans "Message filters" %}{% endblock %}
{% block menu %}{% sfilters_menu user %}{% endblock %}
{% block content %}
{% if messages %}
{% display_messages messages %}
{% endif %}
<div id="sets_list">
<ul>
{% if active_script %}
<li>
<a href="{{ active_script }}" name="loadscript">{{ active_script }}</a>
<img src="{{ MEDIA_URL }}pics/active.png" />
</li>
{% endif %}
{% for script in scripts %}
<li><a href="{{ script }}" name="loadscript">{{ script }}</a></li>
{% endfor %}
</ul>
</div>
<div id="set_content">
</div>
<div class="spacer"></div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "layout_simple.html" %}
{% load i18n %}
{% block html_head %}
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}sievefilters/css/style.css" />
<script src="{{ MEDIA_URL }}js/form.js" type="text/javascript"></script>
{% endblock %}
{% block content %}
<div class="title"><h1>{{ title }}</h1></div>
{% if error %}<div id="error" class="error">{{ error }}</div>{% endif %}
<form action="" method="POST" id="{{ fname }}" name="{{ fname }}">
{% include "common/genericform.html" %}
<div id="submit_container">
<input type="submit" name="submit" value='{{ submit_label }}'/>
</div>
</form>
{% endblock %}
@@ -0,0 +1,6 @@
<form action="{% url extensions.sievefilters.views.savescript %}"
method="POST" id="feditor" name="feditor">
<input type="hidden" name="scriptname" value="{{ name }}" />
<textarea id="scriptcontent" name="scriptcontent">{{ scriptcontent }}</textarea>
</form>
@@ -0,0 +1,36 @@
from django import template
from django.template.loader import render_to_string
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse
from modoboa.extensions.sievefilters import views
register = template.Library()
from modoboa.lib import static_url
@register.simple_tag
def sfilters_menu(user):
entries = [
{"name" : "newfilterset",
"img" : static_url("pics/add.png"),
"label" : _("New filters set"),
"url" : reverse(views.new_filters_set),
"class" : "boxed",
"rel" : "{handler:'iframe',size:{x:300,y:170}}"
},
{"name" : "savescript",
"img" : static_url("pics/save.png"),
"label" : _("Save filters set"),
"url" : ""},
{"name" : "activatescript",
"img" : static_url("pics/active.png"),
"label" : _("Activate filters set"),
"url" : reverse(views.activate_filters_set)},
{"name" : "deletescript",
"img" : static_url("pics/remove.png"),
"label" : _("Remove filters set"),
"url" : reverse(views.delete_filters_set),}
]
return render_to_string('common/menulist.html',
{"entries" : entries, "user" : user})
+43
View File
@@ -0,0 +1,43 @@
if __name__ == "__main__":
clt = ManageSieveClient("mail.koalabs.org", 2000)
if clt.connect("tonio@ngyn.org", "aka/12;c", starttls=True):
print clt.get_sasl_mechanisms()
print clt.get_sieve_capabilities()
print clt.havespace("test", 45)
if not clt.putscript("myscript", """require ["fileinto", "envelope"];
if envelope :contains "to" "tmartin+sent" {
fileinto "INBOX.sent";
}
"""):
print clt.errmsg
if not clt.putscript("test", """#comment
InvalidSieveCommand
"""):
print clt.errmsg
clt.deletescript("thescript")
if not clt.setactive("myscript"):
print clt.errmsg
print clt.listscripts()
sc = clt.getscript("myscript")
if sc is None:
print clt.errmsg
else:
print sc
if not clt.renamescript("myscript", "thescript"):
print clt.errmsg
clt.setactive("")
if not clt.deletescript("thescript"):
print clt.errmsg
else:
print clt.errmsg
clt.logout()
+10
View File
@@ -0,0 +1,10 @@
from django.conf.urls.defaults import *
urlpatterns = patterns('modoboa.extensions.sievefilters.views',
(r'^$', 'index'),
(r'^getscript/$', 'getscript'),
(r'^savescript/$', 'savescript'),
(r'^newfs/$', 'new_filters_set'),
(r'^deletefs/$', 'delete_filters_set'),
(r'^activatefs/$', 'activate_filters_set'),
)
+103
View File
@@ -0,0 +1,103 @@
# coding: utf-8
from django.http import HttpResponse
from django.contrib.auth.decorators import login_required
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse
from modoboa.lib import _render, ajax_response
from modoboa.auth.lib import get_password
from lib import *
from forms import *
@login_required
def index(request, tplname="sievefilters/index.html"):
sc = SieveClient(user=request.user.username,
password=request.session["password"])
active_script, scripts = sc.listscripts()
return _render(request, tplname, {
"active_script" : active_script,
"scripts" : sorted(scripts)
})
@login_required
def getscript(request):
if not request.GET.has_key("name") or request.GET["name"] == "":
return
sc = SieveClient(user=request.user.username,
password=request.session["password"])
try:
content = sc.getscript(request.GET["name"])
except SieveClientError, e:
pass
return ajax_response(request, template="sievefilters/rawfilter.html",
name=request.GET["name"], scriptcontent=content)
@login_required
def savescript(request):
if not request.POST.has_key("scriptname") or \
not request.POST.has_key("scriptcontent"):
return
sc = SieveClient(user=request.user.username,
password=request.session["password"])
try:
sc.pushscript(request.POST["scriptname"],
request.POST["scriptcontent"])
except SieveClientError, e:
error = str(e)
return ajax_response(request, "ko", respmsg=error)
return ajax_response(request, respmsg=_("Filters set saved"))
@login_required
def new_filters_set(request, tplname="sievefilters/newfiltersset.html"):
ctx = {"title" : _("Create a new filters set"),
"fname" : "newfiltersset",
"submit_label" : _("Create"),
"withmenu" : False,
"withunseen" : False}
if request.method == "POST":
form = FiltersSetForm(request.POST)
error = None
if form.is_valid():
sc = SieveClient(user=request.user.username,
password=request.session["password"])
try:
sc.pushscript(form.cleaned_data["name"], "# Empty script",
form.cleaned_data["active"])
except SieveClientError, e:
error = str(e)
else:
return ajax_response(request, url=reverse(index),
respmsg=_("Filters set created"))
ctx["form"] = form
ctx["error"] = error
return ajax_response(request, status="ko", template=tplname, **ctx)
ctx["form"] = FiltersSetForm()
return _render(request, tplname, ctx)
@login_required
def delete_filters_set(request):
if not request.GET.has_key("name") or \
request.GET["name"] == "":
return
sc = SieveClient(user=request.user.username,
password=request.session["password"])
try:
sc.deletescript(request.GET["name"])
except SieveClientError, e:
return ajax_response(request, "ko", respmsg=str(e))
return ajax_response(request, respmsg=_("Filters set deleted"))
@login_required
def activate_filters_set(request):
if not request.GET.has_key("name") or \
request.GET["name"] == "":
return
sc = SieveClient(user=request.user.username,
password=request.session["password"])
try:
sc.activatescript(request.GET["name"])
except SieveClientError, e:
return ajax_response(request, "ko", respmsg=str(e))
return ajax_response(request, respmsg=_("Filters set activated"))
+6 -46
View File
@@ -14,9 +14,9 @@ import email.utils
import lxml
from django.utils.translation import ugettext as _
from django.conf import settings
from modoboa.lib import decode, tables, imap_utf7, Singleton
from modoboa.lib import decode, u2u_decode, tables, \
imap_utf7, static_url, ConnectionsManager, parameters
from modoboa.lib.email_listing import MBconnector, EmailListing, Email
from modoboa.lib import tables, imap_utf7, parameters, static_url, u2u_decode
class WebmailError(Exception):
errorexpr = re.compile("\[([^\]]+)\]\s*([^\.]+)")
@@ -113,29 +113,6 @@ class IMAPheader(object):
def parse_subject(value, **kwargs):
return decode(u2u_decode.u2u_decode(value))
class ConnectionsManager(type):
def __init__(cls, name, bases, dict):
super(ConnectionsManager, cls).__init__(name, bases, dict)
cls.instances = {}
def __call__(cls, **kwargs):
key = None
if kwargs.has_key("user"):
key = kwargs["user"]
else:
return None
if not cls.instances.has_key(key):
cls.instances[key] = None
if kwargs.has_key("password"):
kwargs["password"] = decrypt(kwargs["password"])
if cls.instances[key] is None:
cls.instances[key] = \
super(ConnectionsManager, cls).__call__(**kwargs)
else:
cls.instances[key].refresh(key, kwargs["password"])
return cls.instances[key]
class IMAPconnector(object):
__metaclass__ = ConnectionsManager
@@ -248,6 +225,10 @@ class IMAPconnector(object):
for mb in data:
flags, delimiter, name = list_response_pattern.match(mb).groups()
name = name.strip('"').decode("imap4-utf-7")
# Dovecot stores user scripts into a specific folder, I'm
# wondering why this folder appears in 'list' result?
if name in ["dovecot.sieve"]:
continue
if re.search("\%s" % delimiter, name):
parts = name.split(".")
if not descr.has_key("path"):
@@ -642,24 +623,3 @@ def find_images_in_body(body):
return lxml.html.tostring(html), parts
def encrypt(clear):
from Crypto.Cipher import AES
import base64
obj = AES.new(parameters.get_admin("SECRET_KEY"),
AES.MODE_ECB)
if len(clear) % AES.block_size:
clear += "\0" * (AES.block_size - len(clear) % AES.block_size)
ciph = obj.encrypt(clear)
ciph = base64.b64encode(ciph)
return ciph
def decrypt(ciph):
from Crypto.Cipher import AES
import base64
obj = AES.new(parameters.get_admin("SECRET_KEY"), AES.MODE_ECB)
ciph = base64.b64decode(ciph)
clear = obj.decrypt(ciph)
return clear.rstrip('\0')
-11
View File
@@ -6,11 +6,8 @@ from modoboa.lib import events, parameters, static_url
def init():
events.register("UserMenuDisplay", menu)
events.register("UserLogin", userlogin)
events.register("UserLogout", userlogout)
parameters.register_admin("SECRET_KEY", type="string", deflt="abcdefghijklmnop",
help=_("Key used to encrypt/decrypt passwords"))
parameters.register_admin("IMAP_SERVER", type="string",
deflt="127.0.0.1",
help=_("Address of your IMAP server"))
@@ -67,7 +64,6 @@ def init():
def destroy():
events.unregister("UserMenuDisplay", menu)
events.unregister("UserLogin", userlogin)
parameters.unregister_app("webmail")
def infos():
@@ -93,13 +89,6 @@ def menu(**kwargs):
"img" : static_url("pics/webmail.png")}
]
def userlogin(**kwargs):
from lib import IMAPconnector, encrypt
if kwargs["request"].user.id == 1:
return
kwargs["request"].session["password"] = encrypt(kwargs["password"])
def userlogout(**kwargs):
from lib import IMAPconnector
+2 -1
View File
@@ -14,6 +14,7 @@ from modoboa.lib import parameters, _render, _render_error, \
getctx, is_not_localadmin, _render_to_string, split_mailbox, \
ajax_response
from modoboa.lib.email_listing import parse_search_parameters, Paginator
from modoboa.auth.lib import *
from lib import *
from forms import *
from templatetags.webextras import *
@@ -365,7 +366,7 @@ def send_mail(request, withctx=False, origmsg=None, posturl=None):
error = str(text)
if error is None:
if parameters.get_admin("SMTP_AUTHENTICATION") == "yes":
s.login(request.user.username, decrypt(request.session["password"]))
s.login(request.user.username, get_password(request))
s.sendmail(msg['From'], rcpts, msg.as_string())
s.quit()
sentfolder = parameters.get_user(request.user, "SENT_FOLDER")
+33 -11
View File
@@ -12,6 +12,7 @@ from django.template.loader import render_to_string
from django.utils.translation import ugettext as _
from django.utils import simplejson
from modoboa.lib import parameters
from modoboa.auth.lib import decrypt
def exec_cmd(cmd, **kwargs):
import subprocess
@@ -94,6 +95,8 @@ def ajax_response(request, status="ok", respmsg=None,
ctx[k] = v
if template is not None:
content = _render_to_string(request, template, ctx)
elif kwargs.has_key("content"):
content = kwargs["content"]
else:
content = ""
jsonctx = {"status" : status, "content" : content}
@@ -156,17 +159,6 @@ def db_table_exists(table, cursor=None):
else:
return table in table_names
class Singleton(type):
def __init__(cls, name, bases, dict):
super(Singleton, cls).__init__(name, bases, dict)
cls.instance = None
def __call__(cls, *args, **kwargs):
if cls.instance is None:
cls.instance = super(Singleton, cls).__call__(*args, **kwargs)
return cls.instance
def _check_password(password, crypted):
scheme = parameters.get_admin("PASSWORD_SCHEME", app="admin")
if scheme == "crypt":
@@ -193,3 +185,33 @@ def split_mailbox(mailbox):
domain = parts[-1]
address = "@".join(parts[:-1])
return (address, domain)
class ConnectionsManager(type):
"""Singleton pattern implementation
This class is specialized in connection management.
"""
def __init__(cls, name, bases, dict):
super(ConnectionsManager, cls).__init__(name, bases, dict)
cls.instances = {}
def __call__(cls, **kwargs):
key = None
if kwargs.has_key("user"):
key = kwargs["user"]
else:
return None
if not cls.instances.has_key(key):
cls.instances[key] = None
if kwargs.has_key("password"):
kwargs["password"] = decrypt(kwargs["password"])
if cls.instances[key] is None:
cls.instances[key] = \
super(ConnectionsManager, cls).__call__(**kwargs)
else:
cls.instances[key].refresh(key, kwargs["password"])
return cls.instances[key]
class ConnectionError(Exception):
pass
+73
View File
@@ -0,0 +1,73 @@
# encoding: utf-8
import datetime
from south.db import db
from south.v2 import DataMigration
from django.db import models
class Migration(DataMigration):
def forwards(self, orm):
"Write your forwards methods here."
p = orm.Parameter.objects.get(name="webmail.SECRET_KEY")
p.name = "auth.SECRET_KEY"
p.save()
def backwards(self, orm):
"Write your backwards methods here."
p = orm.Parameter.objects.get(name="auth.SECRET_KEY")
p.name = "webmail.SECRET_KEY"
p.save()
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'lib.parameter': {
'Meta': {'object_name': 'Parameter'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'value': ('django.db.models.fields.CharField', [], {'max_length': '255'})
},
'lib.userparameter': {
'Meta': {'object_name': 'UserParameter'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'value': ('django.db.models.fields.CharField', [], {'max_length': '255'})
}
}
complete_apps = ['lib']
+1
View File
@@ -102,6 +102,7 @@ INSTALLED_APPS = (
'modoboa.extensions.webmail',
'modoboa.extensions.stats',
'modoboa.extensions.amavis_quarantine',
'modoboa.extensions.sievefilters',
)
AUTHENTICATION_BACKENDS = (
+4 -1
View File
@@ -6,7 +6,6 @@ body {
}
div.row {
/*position: relative;*/
clear: both;
padding-top: 10px;
padding-left: 20px;
@@ -134,3 +133,7 @@ td.even {
background: #eee;
color: black;
}
.spacer {
clear: both;
}
+12 -1
View File
@@ -13,6 +13,10 @@ var error_callbacks = {
}
};
var success_callbacks = {
};
var extra_data_callbacks = {
};
@@ -29,6 +33,10 @@ function register_error_callback(name, callback) {
return register_callback(error_callbacks, name, callback);
}
function register_success_callback(name, callback) {
return register_callback(success_callbacks, name, callback);
}
function register_extra_data_callback(name, callback) {
return register_callback(extra_data_callbacks, name, callback);
}
@@ -80,7 +88,10 @@ function ajax_submit(event) {
} else if ($defined(response.url)) {
parent.location.href = response.url;
}
parent.SqueezeBox.close();
if ($defined(success_callbacks[myid])) {
success_callbacks[myid](response);
}
parent.SqueezeBox.close();
}
},
onFailure: function(xhr) {
+14 -4
View File
@@ -22,13 +22,11 @@ var InfoBox = new Class({
"font-size" : "1em",
"font-weight" : "bold",
"text-align" : "left",
"width" : this.options.width + "px",
"padding" : "10px",
"position" : "absolute",
"top" : "0px",
"left" : boxleft + "px",
"display" : "none"
});
this.calcsize();
this.setProfile("gray");
this.spinner = new Element("img", {
"src" : "/static/css/spinner.gif"
@@ -38,12 +36,24 @@ var InfoBox = new Class({
this.label = new Element("label").set("html", this.options.message);
this.label.setStyles({
"margin-left" : "10px"
"white-space": "pre-line"
/*"margin-left" : "10px"*/
});
this.label.inject(this.box);
this.box.inject($(document.body));
},
calcsize: function(width) {
var w = ($defined(width) ? width : this.options.width);
var boxleft = $(document.body).getSize().x / 2 - w / 2;
this.box.setStyles({
width: w + "px",
left: boxleft + "px",
top: "0px"
});
},
setMessage: function(text) {
this.label.set("html", text);
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B