Merge branch 'master' into refactoring

Conflicts:
	modoboa/admin/models.py
	modoboa/admin/templatetags/admin_extras.py
	modoboa/admin/views.py
	modoboa/core/app_settings.py
	modoboa/extensions/admin/forms.py
	modoboa/extensions/admin/tests.py
	modoboa/extensions/amavis/views.py
	modoboa/extensions/limits/tests.py
	modoboa/extensions/postfix_autoreply/models.py
	modoboa/extensions/sievefilters/views.py
	modoboa/extensions/webmail/fetch_parser.py
	modoboa/extensions/webmail/imaputils.py
	modoboa/extensions/webmail/lib.py
	modoboa/extensions/webmail/templatetags/webextras.py
	modoboa/extensions/webmail/views.py
This commit is contained in:
Antoine Nguyen
2013-09-24 17:18:05 +02:00
61 changed files with 3496 additions and 2197 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ as an administration panel or a webmail.
Modoboa integrates with well known software such as `Postfix
<http://postfix.org/>`_ or `Dovecot <http://dovecot.org/>`_. A SQL
database (`MySQL <http://www.mysql.com>`_, `PostgreSQL
<http://www.postgresql.org/>`_ or `SQLite <http://www.sqlite.org>`_)
is used as a central point of communication between all components.
<http://www.postgresql.org/>`_) is used as a central point of
communication between all components.
Modoboa is developed with modularity in mind, expanding it is really
easy. Actually, all current features are extensions.
+2 -2
View File
@@ -16,7 +16,7 @@ existing `PostfixAdmin (version 2.3.3+)
First, you must follow the :ref:`installation` step to create a fresh
Modoboa database.
Once done, edit the *setting.py* file. First, add a new database
Once done, edit the :file:`settings.py` file. First, add a new database
connection named ``pfxadmin`` into the ``DATABASES`` variable
corresponding to your PostfixAdmin setup::
@@ -56,7 +56,7 @@ used by PostfixAdmin, check its configuration file if you're not sure)
Click on the *Save* button.
The procedure is over, edit the *settings.py* file and:
The procedure is over, edit the :file:`settings.py` file and:
* remove the ``pfxadmin`` database connection from the ``DATABASES``
variable
+5 -6
View File
@@ -4,9 +4,9 @@ Using the virtual machine
Introduction
============
A virtual machine with a ready-to-use *Modoboa* setup is available
`here <http://modoboa.org/resources/modoboa.vmdk.bz2>`_. It is composed
of the following components:
A virtual machine with a ready-to-use Modoboa setup is available `here
<http://modoboa.org/resources/modoboa.vmdk.bz2>`_. It is composed of
the following components:
* Debian 6.0 (squeeze)
* Modoboa and its prerequisites
@@ -32,7 +32,7 @@ If you can't use the vmdk format, you can use `qemu
Then, just use your prefered virtualization software (qemu, kvm,
virtualbox, etc.) to start the machine. You'll need to configure at
least one bridged network interface if you want to be able to play
with *Modoboa*, ie. your machine must be visible from your network.
with Modoboa, ie. your machine must be visible from your network.
The default network interface of the machine (``eth0``) is configured
to use the DHCP protocol.
@@ -49,7 +49,7 @@ root demo the root user
demo demo an unpriviliged user
===== ======== ====================
To connect to *Modoboa*, first connect to the system and retrieve its
To connect to Modoboa, first connect to the system and retrieve its
current network address like this::
$ /sbin/ifconfig eth0
@@ -72,4 +72,3 @@ admin@demo.local admin Administrator of the domain *demo.local*.
user@demo.local user Simple user. Can access to applications.
================ ======== ============================================
+3 -3
View File
@@ -33,12 +33,12 @@ The required glue
*****************
To create a new plugin, just start a new django application like
this (into modoboa's directory)::
this (into Modoboa's directory)::
$ python manage.py startapp
Then, you need to register this application using the provided
API. Just copy/paste the following example into the *__init__.py* file
API. Just copy/paste the following example into the :file:`__init__.py` file
of the future extension::
from modoboa.extensions import ModoExtension, exts_pool
@@ -72,7 +72,7 @@ of the future extension::
exts_pool.register_extension(MyExtension)
Once done, simply add your plugin's module name to the
``INSTALLED_APPS`` variable located inside *settings.py*. Optionaly,
``INSTALLED_APPS`` variable located inside :file:`settings.py`. Optionaly,
run ``python manage.py syncdb`` if your plugin provides custom tables
and ``python manage.py collectstatic`` to update static files.
+8 -8
View File
@@ -86,7 +86,7 @@ Custom logo
You have the possibility to use a custom logo instead of the default
one on the login page.
To do so, open the *settings.py* file and add a
To do so, open the :file:`settings.py` file and add a
``MODOBOA_CUSTOM_LOGO`` variable. This variable must contain the
relative URL of your logo under ``MEDIA_URL``. For example::
@@ -107,7 +107,7 @@ Host configuration
To manipulate mailboxes on the filesystem, you must allow the user who
runs Modoboa to execute commands as the user who owns mailboxes.
To do so, edit the */etc/sudoers* file and add the following inside::
To do so, edit the :file:`/etc/sudoers` file and add the following inside::
<user_that_runs_modoboa> ALL=(<mailboxes owner>) NOPASSWD: ALL
@@ -121,7 +121,7 @@ Time zone and language
Modoboa is available in many languages.
To specify the default language to use, edit the *settings.py* file
To specify the default language to use, edit the :file:`settings.py` file
and modify the ``LANGUAGE_CODE`` variable::
LANGUAGE_CODE = 'fr' # or 'en' for english, etc.
@@ -143,7 +143,7 @@ Modoboa uses `Django's session framework
<https://docs.djangoproject.com/en/dev/topics/http/sessions/?from=olddocs>`_
to store per-user information.
Few parameters need to be set in the *settings.py* configuration
Few parameters need to be set in the :file:`settings.py` configuration
file to make Modoboa behave as expected::
SESSION_EXPIRE_AT_BROWSER_CLOSE = False # Default value
@@ -151,7 +151,7 @@ file to make Modoboa behave as expected::
This parameter is optional but you must ensure it is set to ``False``
(the default value).
The default configuration file provided by the *modoboa-admin.py*
The default configuration file provided by the ``modoboa-admin.py``
command is properly configured.
****
@@ -163,7 +163,7 @@ LDAP
Authentication
==============
*Modoboa* supports external LDAP authentication using the following extra components:
Modoboa supports external LDAP authentication using the following extra components:
* `Python LDAP client <http://www.python-ldap.org/>`_
* `Django LDAP authentication backend <http://pypi.python.org/pypi/django-auth-ldap>`_
@@ -172,7 +172,7 @@ If you want to use this feature, you must first install those components::
$ pip install python-ldap django-auth-ldap
Then, all you have to do is to modify the *settings.py* file. Add a
Then, all you have to do is to modify the :file:`settings.py` file. Add a
new authentication backend to the `AUTHENTICATION_BACKENDS` variable,
like this::
@@ -247,7 +247,7 @@ If you need additional parameters, you will find a detailled
documentation `here <http://packages.python.org/django-auth-ldap/>`_.
Once the authentication is properly configured, the users defined in
your LDAP directory will be able to connect to *Modoboa*, the associated
your LDAP directory will be able to connect to Modoboa, the associated
domain and mailboxes will be automatically created if needed.
.. note::
+8 -8
View File
@@ -30,8 +30,8 @@ You can choose between two options:
* Use the Python package available on the `PyPI <http://pypi.python.org/pypi>`_
* Download the sources tarball
The easiest one is to install it from the *PyPI*. Just run the
following command and you're done::
The easiest one is to install it from PyPI. Just run the following
command and you're done::
$ pip install modoboa
@@ -51,13 +51,13 @@ Fortunately, all major distributions include a ready-to-use
package. On Debian/Ubuntu::
$ apt-get install libcairo2-dev libpango1.0-dev librrd-dev
$ pip install python-rrdtool
$ apt-get install python-rrdtool
`virtualenv <http://www.virtualenv.org/en/latest/>`_ users
==========================================================
When you deploy an application using *virtualenv*, you may have to
compile some dependencies. For example, *modoboa* relies on *lxml*,
When you deploy an application using virtualenv, you may have to
compile some dependencies. For example, modoboa relies on lxml,
which is a C python module. In order to install it, you will need to
install the following requirements:
@@ -65,9 +65,9 @@ install the following requirements:
* libxslt development files
* libxml2 development files
On a *Debian* like system, just run the following command::
On a Debian like system, just run the following command::
$ apt-get instal python-dev libxml2-dev libxslt-dev
$ apt-get install python-dev libxml2-dev libxslt-dev
.. _database:
@@ -75,7 +75,7 @@ On a *Debian* like system, just run the following command::
Database
********
Thanks to *django*, Modoboa supports several databases. Depending on
Thanks to Django, Modoboa supports several databases. Depending on
the one you will use, you must install the appropriate python package:
* `mysqldb <http://mysql-python.sourceforge.net/>`_ for `MySQL <http://www.mysql.com>`_
+14 -14
View File
@@ -50,11 +50,11 @@ This plugin provides a simple management frontend for `amavisd-new
The per-domain policies feature only works for new
installations. Currently, you can't use modoboa with an existing
database (ie. with data in *users* and *policies* tables).
database (ie. with data in ``users`` and ``policies`` tables).
.. note::
This plugin requires *amavisd-new* version **2.7.0** or higher. If
This plugin requires amavisd-new version **2.7.0** or higher. If
you're planning to use the :ref:`selfservice`, you'll need version
**2.8.0**.
@@ -62,7 +62,7 @@ Database
========
You must tell to Modoboa where it can find the amavis
database. Inside *settings.py*, add a new connection to the
database. Inside :file:`settings.py`, add a new connection to the
``DATABASES`` variable like this::
DATABASES = {
@@ -81,7 +81,7 @@ Replace values between ``<>`` with yours.
.. note::
Modoboa doesn't create *amavis* tables. You need to install them
Modoboa doesn't create amavis tables. You need to install them
following the `official documentation
<http://www.amavis.org/#doc>`_.
@@ -106,7 +106,7 @@ Release messages
To release messages, first take a look at `this page
<http://www.ijs.si/software/amavisd/amavisd-new-docs.html#quar-release>`_. It
explains how to configure *amavisd-new* to listen somewhere for the
explains how to configure amavisd-new to listen somewhere for the
AM.PDP protocol. This protocol is used to send requests.
Below is an example of a working configuration::
@@ -126,7 +126,7 @@ Below is an example of a working configuration::
Don't forget to update the ``inet_acl`` list if you plan to release from
the network.
Once *amavisd-new* is configured, just tell Modoboa where it can find
Once amavisd-new is configured, just tell Modoboa where it can find
the *release server* by modifying the following parameters in the
online panel:
@@ -212,7 +212,7 @@ the required identifiers.
To activate this feature, go the administration panel and set the
**Enable self-service mode** parameter to yes.
The last step is to customize the notification messages *amavis*
The last step is to customize the notification messages amavis
sends. The most important is to embark a direct link. Take a look at
the `README.customize <http://amavis.org/README.customize.txt>`_ file to
learn what you're allowed to do.
@@ -229,7 +229,7 @@ Graphical statistics
This plugin collects various statistics about emails traffic on your
server. It parses a log file to collect information, store it into RRD
files (see `rrdtool <http://oss.oetiker.ch/rrdtool/>`_)and then
files (see `rrdtool <http://oss.oetiker.ch/rrdtool/>`_) and then
generates graphics in PNG format.
To use it, go to the online parameters panel and adapt the following
@@ -273,15 +273,15 @@ Postifx auto-reply messages
***************************
This plugin let users define an auto-reply message (*vacation*). It is
based on *postfix* capabilities.
based on Postfix capabilities.
The user that executes the autoreply script needs to access
*settings.py*. You must apply proper permissions on this file. For
example, if *settings.py* belongs to *www-data:www-data*, you can add
the *vmail* user to the *www-data* group and set the read permission
:file:`settings.py`. You must apply proper permissions on this file. For
example, if :file:`settings.py` belongs to ``www-data:www-data``, you can add
the ``vmail`` user to the ``www-data`` group and set the read permission
for the group.
To make *postfix* use this feature, you need to update your
To make Postfix use this feature, you need to update your
configuration files as follows:
``/etc/postfix/main.cf``::
@@ -334,7 +334,7 @@ Two working modes are available:
To use this plugin, your hosting setup must include a *ManageSieve*
server and your local delivery agent must understand the *Sieve*
language. Don't panic, *Dovecot* supports both :-) (refer to
language. Don't panic, Dovecot supports both :-) (refer to
:ref:`dovecot` to know how to enable those features).
.. note::
+54 -30
View File
@@ -15,6 +15,16 @@ newest versions of Modoboa.
Latest version
**************
.. warning::
If you use a version **prior to 0.9.5**, please migrate in two
steps:
#. first migrate to 0.9.5
#. then migrate to the latest version
If you try to migrate directly, the operation will fail.
Starting with version 0.9.1, Modoboa comes as a standard django
application. Fetch the latest version (see :ref:`get_modoboa`) and
install it.
@@ -33,13 +43,26 @@ Finally, follow the common procedure::
$ python manage.py syncdb --migrate
$ python manage.py collectstatic
1.0.1: operations on mailboxes
==============================
The way Modoboa handles **rename** and **delete** operations on
mailboxes has been improved. Make sure to consult :ref:`fs_operations`
and :ref:`Postfix configuration <postfix_config>`. Look at the
``smtpd_recipient_restrictions`` setting.
Run ``modoboa-admin.py postfix_maps --dbtype <mysql|postgres>
<tempdir>`` and compare the files with those that postfix currently
use. Make necessary updates in light of the differences
1.0.0: production ready, at last
================================
Configuration file update
-------------------------
Several modifications need to be done into *settings.py*.
Several modifications need to be done into :file:`settings.py`.
#. Add the following import statement::
@@ -170,9 +193,10 @@ Postfix and Dovecot configuration update
It is necessary to update the queries used to retrieve users and mailboxes:
#. Replace all occurences of ``auth_user`` by ``admin_user``
#. Run ``modoboa-admin.py postfix_maps --dbtype <mysql|postgres> <tempdir>`` and compare the files with those that postfix currently
use. Make necessary updates in light of the differences
#. Into *dovecot-sql.conf*, update the ``user_query`` query, refer to
#. Into :file:`dovecot-sql.conf`, update the ``user_query`` query, refer to
:ref:`dovecot_mysql_queries` or :ref:`dovecot_pg_queries`
#. Update dovecot's configuration to activate the new :ref:`quota related features <dovecot_quota>`
@@ -189,7 +213,7 @@ may encounter the following issues:
#. South fails to migrate ``reversion``
Due to the admin user model change, the script *0001_initial.py*
Due to the admin user model change, the script :file:`0001_initial.py`
may fail. Just deactivate ``reversion`` from ``INSTALLED_APPS`` and
run the command again. Once done, reactivate ``reversion`` and run
the command one last time.
@@ -198,7 +222,7 @@ may encounter the following issues:
0.9.4: administrative panel performance improved
================================================
#. Edit the *settings.py* file and remove
#. Edit the :file:`settings.py` file and remove
``'django.contrib.auth.backends.ModelBackend'`` from the
``AUTHENTICATION_BACKENDS`` variable
@@ -211,10 +235,10 @@ For this version, we recommend to install a new instance (see
Then, copy the following content from the old installation to the new
one:
* The ``media`` directory
* The :file:`media` directory
* The directory containing RRD files if you use the :ref:`stats` plugin
Don't copy the old *settings.py* file, just keep the new one and
Don't copy the old :file:`settings.py` file, just keep the new one and
modify it (see :ref:`database` and :ref:`timezone_lang`).
Migrate your database (see :ref:`latestversion`).
@@ -242,16 +266,16 @@ requires a migration.
.. note::
Many files have been renamed/removed for this version. I recommend
that you backup important files (*settings.py*, etc.) elsewhere
(ie. */tmp* for example). Then, remove the *modoboa* directory,
(ie. :file:`/tmp` for example). Then, remove the :file:`modoboa` directory,
extract the new tarball at the same place, rename the new directory
to *modoboa* and copy the files you've just backup into it.
to :file:`modoboa` and copy the files you've just backup into it.
.. note::
If the first super administrator you created is named ``admin``,
its password will be changed to ``password`` at the end of this
upgrade. Don't forget to modify it!
#. Edit the *settings.py* file and update the following variables
#. Edit the :file:`settings.py` file and update the following variables
(just copy/paste their new content)::
MIDDLEWARE_CLASSES = (
@@ -308,10 +332,10 @@ requires a migration.
$ ./manage.py collectstatic
#. If you are using the *stats* extension, please rename the
*<modoboa_dir>/static/stats* directory to *<modoboa_dir>/media/stats*
:file:`<modoboa_dir>/static/stats` directory to :file:`<modoboa_dir>/media/stats`
and change the value of the ``IMG_ROOTDIR`` parameter (go to the adminstration panel)
#. Restart the python instance(s) that serve *Modoboa*
#. Restart the python instance(s) that serve Modoboa
#. Log into Modoboa, go to *Modoboa > Extensions*, uncheck all
extensions, save. Then, check the extensions you want to use and
@@ -321,7 +345,7 @@ requires a migration.
(see :ref:`webservers`)
#. **For Dovecot users only**, you need to modify the
``password_query`` (file */etc/dovecot/dovecot-sql.conf* by default
``password_query`` (file :file:`/etc/dovecot/dovecot-sql.conf` by default
on a Debian system) like this::
password_query = SELECT email AS user, password FROM auth_user WHERE email='%u'
@@ -329,7 +353,7 @@ requires a migration.
0.8.8: CSV import feature and minor fixes
=========================================
#. Edit the *settings.py* file and add
#. Edit the :file:`settings.py` file and add
``'modoboa.lib.middleware.AjaxLoginRedirect'`` to the
``MIDDLEWARE_CLASSES`` variable like this::
@@ -344,7 +368,7 @@ requires a migration.
'modoboa.extensions.webmail.middleware.WebmailErrorMiddleware',
)
#. Still inside *settings.py*, modify the ``DATABASE_ROUTERS``
#. Still inside :file:`settings.py`, modify the ``DATABASE_ROUTERS``
variable like this::
DATABASE_ROUTERS = ["modoboa.extensions.amavis_quarantine.dbrouter.AmavisRouter"]
@@ -353,7 +377,7 @@ requires a migration.
0.8.7: per-user language selection
==================================
#. Edit the *settings.py* file and add the
#. Edit the :file:`settings.py` file and add the
``'django.middleware.locale.LocaleMiddleware'`` middleware to the
``MIDDLEWARE_CLASSES`` variable like this::
@@ -386,7 +410,7 @@ requires a migration.
=========================================================
#. Just update your configuration if you are using the quarantine
plugin. Open *settings.py*, move the database configuration from
plugin. Open :file:`settings.py`, move the database configuration from
the ``DB_CONNECTIONS`` variable to the ``DATABASES`` variable, like
this::
@@ -408,7 +432,7 @@ requires a migration.
DATABASE_ROUTERS = ["modoboa.extensions.amavis_quarantine.dbrouter.AmavisRouter"]
#. Remove the deprecated ``DB_CONNECTIONS`` variable from *settings.py*.
#. Remove the deprecated ``DB_CONNECTIONS`` variable from :file:`settings.py`.
0.8.5: new "Sieve filters" plugin, improved admin app
=====================================================
@@ -419,7 +443,7 @@ requires a migration.
$ python manage.py migrate admin
#. Add ``modoboa.auth`` and ``modoboa.extensions.sievefilters`` to the
``INSTALLED_APPS`` variable in *settings.py*.
``INSTALLED_APPS`` variable in :file:`settings.py`.
#. Go the *Settings/Extensions* panel, deactivate and activate your
extensions, it will update all the symbolic links.
@@ -427,7 +451,7 @@ requires a migration.
0.8.4: folders manipulation support (webmail) and bugfixes
==========================================================
#. Update the ``MIDDLEWARE_CLASSES`` variable in *settings.py*::
#. Update the ``MIDDLEWARE_CLASSES`` variable in :file:`settings.py`::
MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware',
@@ -442,7 +466,7 @@ requires a migration.
extensions, it will update all the symbolic links to the new format.
#. Optional: update the ``DATABASES`` and ``TEMPLATE_LOADERS``
variables in *settings.py* to remove warning messages (appearing with
variables in :file:`settings.py` to remove warning messages (appearing with
Django 1.3)::
DATABASES = {
@@ -464,14 +488,14 @@ requires a migration.
0.8.3: admin application refactoring and more
=============================================
#. Migrate the *admin* application::
#. Migrate the ``admin`` application::
$ python manage.py migrate admin
#. Update SQL queries used in your environnement (see
:ref:`postfix` or :ref:`dovecot`).
#. Update *postfix* configuration so that it can handle domain aliases
#. Update Postfix configuration so that it can handle domain aliases
(see :ref:`postfix`).
@@ -484,9 +508,9 @@ requires a migration.
#. Update your config file and add all extensions to ``INSTALLED_APPS``
(even those you are not going to use).
#. Inside the *<modoboa_dir>/templates/* directory, remove all symbolic links.
#. Download the latest release of ckeditor and extract it into *<modoboa_dir>/static/js/*. It should create a new directory named *ckeditor*.
#. Update the following variables inside *settings.py*::
#. Inside the :file:`<modoboa_dir>/templates/` directory, remove all symbolic links.
#. Download the latest release of ckeditor and extract it into :file:`<modoboa_dir>/static/js/`. It should create a new directory named ``ckeditor``.
#. Update the following variables inside :file:`settings.py`::
MEDIA_ROOT = os.path.join(MODOBOA_DIR, 'static')
MEDIA_URL = '/static/'
@@ -500,9 +524,9 @@ requires a migration.
0.8.1 : project renamed
=======================
#. First, rename the *mailng* directory to *modoboa* and copy all the
content from *modoboa-0.8.1* to *modoboa*.
#. Edit *settings.py* and replace all occurences of mailng by
#. First, rename the ``mailng`` directory to ``modoboa`` and copy all the
content from ``modoboa-0.8.1`` to ``modoboa``.
#. Edit :file:`settings.py` and replace all occurences of mailng by
modoboa. Make sure you don't modify the ``DATABASE`` section as you're
not going to rename your database.
#. Rename the ``MAILNG_DIR`` variable to ``MODOBOA_DIR``.
@@ -536,7 +560,7 @@ Before you start the migration, make sure you have updated your
'mailng.userprefs',
)
Starting with 0.8, mailng.main doesn't exist anymore. You must remove
Starting with 0.8, ``mailng.main`` doesn't exist anymore. You must remove
it from your ``INSTALLED_APPS``.
Finally, run the following commands::
+82 -34
View File
@@ -12,12 +12,12 @@ Modoboa works better with Dovecot 2.0 so the following documentation
is suitable for this combination.
In this section, we assume dovecot's configuration resides in
*/etc/dovecot*, all pathes will be relative to this directory.
:file:`/etc/dovecot`, all pathes will be relative to this directory.
Mailboxes
=========
First, edit the *conf.d/10-mail.conf* and set the ``mail_location``
First, edit the :file:`conf.d/10-mail.conf` and set the ``mail_location``
variable::
# maildir
@@ -45,14 +45,53 @@ Then, edit the ``inbox`` namespace and add the following lines::
It ensures all the special mailboxes will be automaticaly created for
new accounts.
.. note::
.. _fs_operations:
Operations on the file system
-----------------------------
Three operation types are considered:
#. Mailbox creation
#. Mailbox renaming
#. Mailbox deletion
The first one is managed by Dovecot. The last two ones may be managed
by Modoboa if it can access the file system where the mailboxes are
stored (see :ref:`admin-params` to activate this feature).
Those operations are treated asynchronously by a cron script. For
example, when you rename an e-mail address through the web UI, the
associated mailbox on the file system is not modified
directly. Instead of that, a *rename* order is created for this
mailbox. The mailbox will be considered unavailable until the order is
not executed (see :ref:`Postfix configuration <postfix_config>`).
Edit the crontab of the user who owns the mailboxes on the file system::
$ crontab -u <user> -e
And add the following line inside::
* * * * * python <modoboa_site>/manage.py handle_mailbox_operations
.. warning::
The cron script must be executed by the system user owning the mailboxes.
.. warning::
The user running the cron script must have access to the
:file:`settings.py` file of the modoboa instance.
The result of each order is recorded into Modoboa's log. Go to
*Modoboa > Logs* to consult them.
Modoboa is not responsible for mailboxes creation, Dovecot is.
Authentication
==============
To make the authentication work, edit the *conf.d/10-auth.conf* and
To make the authentication work, edit the :file:`conf.d/10-auth.conf` and
uncomment the following line at the end::
#!include auth-system.conf.ext
@@ -64,7 +103,7 @@ uncomment the following line at the end::
#!include auth-static.conf.ext
Then, edit the *conf.d/auth-sql.conf.ext* file and add the following
Then, edit the :file:`conf.d/auth-sql.conf.ext` file and add the following
content inside::
passdb sql {
@@ -81,13 +120,13 @@ content inside::
Make sure to activate only one backend (per type) inside your configuration
(just comment the other ones).
Edit the *dovecot-sql.conf.ext* and modify the configuration according
Edit the :file:`dovecot-sql.conf.ext` and modify the configuration according
to your database engine.
.. _dovecot_mysql_queries:
*MySQL* users
-------------
MySQL users
-----------
::
@@ -105,8 +144,8 @@ to your database engine.
.. _dovecot_pg_queries:
*PostgreSQL* users
------------------
PostgreSQL users
----------------
::
@@ -130,7 +169,7 @@ LDA
===
The LDA is activated by default but you must define a *postmaster*
address. Open the *conf.d/15-lda.conf* file modify the following line::
address. Open the :file:`conf.d/15-lda.conf` file modify the following line::
postmaster_address = postmaster@<domain>
@@ -143,12 +182,12 @@ Modoboa lets adminstrators define per-domain and/or per-account limits
(quota). It also lists the current quota usage of each account. Those
features require Dovecot to be configured in a specific way.
Inside *conf.d/10-mail.conf*, add the ``quota`` plugin to the default
Inside :file:`conf.d/10-mail.conf`, add the ``quota`` plugin to the default
activated ones::
mail_plugins = quota
Inside *conf.d/10-master.conf*, update the *dict* service to set
Inside :file:`conf.d/10-master.conf`, update the ``dict`` service to set
proper permissions::
service dict {
@@ -161,7 +200,7 @@ proper permissions::
}
}
Inside *conf.d/20-imap.conf*, activate the ``imap_quota`` plugin::
Inside :file:`conf.d/20-imap.conf`, activate the ``imap_quota`` plugin::
protocol imap {
# ...
@@ -171,13 +210,13 @@ Inside *conf.d/20-imap.conf*, activate the ``imap_quota`` plugin::
# ...
}
Inside *dovecot.conf*, activate the quota SQL dictionary backend::
Inside :file:`dovecot.conf`, activate the quota SQL dictionary backend::
dict {
quota = <driver>:/etc/dovecot/dovecot-dict-sql.conf.ext
}
Inside *conf.d/90-quota.conf*, activate the *quota dictionary* backend::
Inside :file:`conf.d/90-quota.conf`, activate the *quota dictionary* backend::
plugin {
quota = dict:User quota::proxy::quota
@@ -185,7 +224,8 @@ Inside *conf.d/90-quota.conf*, activate the *quota dictionary* backend::
It will tell Dovecot to keep quota usage in the SQL dictionary.
Finally, edit the *dovecot-dict-sql.conf.ext* file and put the following content inside::
Finally, edit the :file:`dovecot-dict-sql.conf.ext` file and put the
following content inside::
connect = host=<db host> dbname=<db name> user=<db user> password=<password>
@@ -208,9 +248,9 @@ Finally, edit the *dovecot-dict-sql.conf.ext* file and put the following content
Database schema update
^^^^^^^^^^^^^^^^^^^^^^
The `admin_quota` table is created by *Django* but unfortunately it
doesn't support `DEFAULT` constraints (it only simulates them when the
ORM is used). As *PostgreSQL* is a bit strict about constraint
The ``admin_quota`` table is created by Django but unfortunately it
doesn't support ``DEFAULT`` constraints (it only simulates them when the
ORM is used). As PostgreSQL is a bit strict about constraint
violations, you must execute the following query manually::
db=> ALTER TABLE admin_quota ALTER COLUMN bytes SET DEFAULT 0;
@@ -279,8 +319,8 @@ work for Modoboa. You should use the following one instead:
CREATE TRIGGER setmboxid AFTER INSERT ON admin_quota
FOR EACH ROW EXECUTE PROCEDURE set_mboxid();
Copy this example into a file (for example: *quota-trigger.sql*) on
server running *postgres* and execute the following commands::
Copy this example into a file (for example: :file:`quota-trigger.sql`) on
server running postgres and execute the following commands::
$ su - postgres
$ psql < /path/to/quota-trigger.sql
@@ -293,7 +333,7 @@ For existing installations, *Dovecot* (> 2) offers a command to
recalculate the current quota usages. For example, if you want to
update all usages, run the following command::
$ doveadmin quota recalc -A
$ doveadm quota recalc -A
Be carefull, it can take a while to execute.
@@ -303,7 +343,7 @@ ManageSieve/Sieve
Modoboa lets users define filtering rules from the web interface. To
do so, it requires *ManageSieve* to be activated on your server.
Inside *conf.d/20-managesieve.conf*, make sure the following lines are
Inside :file:`conf.d/20-managesieve.conf`, make sure the following lines are
uncommented::
protocols = $protocols sieve
@@ -322,7 +362,7 @@ uncommented::
Messages filtering using Sieve is done by the LDA.
Inside *conf.d/15-lda.conf*, activate the ``sieve`` plugin like this::
Inside :file:`conf.d/15-lda.conf`, activate the ``sieve`` plugin like this::
protocol lda {
# Space separated list of plugins to load (default is global mail_plugins).
@@ -330,7 +370,7 @@ Inside *conf.d/15-lda.conf*, activate the ``sieve`` plugin like this::
}
Finally, configure the ``sieve`` plugin by editing the
*conf.d/90-sieve.conf* file. Put the follwing content inside::
:file:`conf.d/90-sieve.conf` file. Put the follwing caontent inside::
plugin {
# Location of the active script. When ManageSieve is used this is actually
@@ -343,7 +383,7 @@ Finally, configure the ``sieve`` plugin by editing the
sieve_dir = ~/sieve
}
Restart *Dovecot*.
Restart Dovecot.
.. _postfix:
@@ -359,20 +399,22 @@ Map files
=========
You first need to create configuration files (or map files) that will
be used by *postfix* to lookup into Modoboa tables.
be used by Postfix to lookup into Modoboa tables.
To automaticaly generate the requested map files and store them in a
directory, run the following command::
$ modoboa-admin.py postfix_maps --dbtype <mysql|postgres> mapfiles
``mapfiles`` is the directory where the files will be stored. Answer the
few questions and you're done.
:file:`mapfiles` is the directory where the files will be
stored. Answer the few questions and you're done.
.. _postfix_config:
Configuration
=============
Use the following configuration in the */etc/postfix/main.cf* file
Use the following configuration in the :file:`/etc/postfix/main.cf` file
(this is just one possible configuration)::
# Stuff before
@@ -393,12 +435,18 @@ Use the following configuration in the */etc/postfix/main.cf* file
mysql:/etc/postfix/sql-email2email.cf,
mysql:/etc/postfix/sql-catchall-aliases.cf
smtpd_recipient_restrictions =
...
check_recipient_access mysql:/etc/postfix/maps/sql-maintain.cf
permit_mynetworks
...
# Stuff after
Then, edit the */etc/postfix/master.cf* file and add the following
Then, edit the :file:`/etc/postfix/master.cf` file and add the following
definition at the end::
dovecot unix - n n - - pipe
flags=DRhu user=vmail:vmail argv=/usr/lib/dovecot/deliver -f ${sender} -d ${recipient}
Restart *Postfix*.
Restart Postfix.
+202 -21
View File
@@ -10,10 +10,19 @@ Web servers
Apache2
*******
.. note::
The following instructions are meant to help you get your site up
and running quickly. However it is not possible for the people
contributing documentation to Modoboa to test every single
combination of web server, wsgi server, distribution, etc. So it is
possible that **your** installation of uwsgi or nginx or Apache or
what-have-you works differently. Keep this in mind.
mod_wgsi
========
First, make sure that *mod_wsgi* is installed on your server.
First, make sure that mod_wsgi is installed on your server.
Create a new virtualhost in your Apache configuration and put the
following content inside::
@@ -37,20 +46,20 @@ following content inside::
WSGIScriptAlias / <path to your site's dir>/wsgi.py
</VirtualHost>
This is just one possible configuration.
This is just one possible configuration.
.. note::
*Django* 1.3 users, please consult this `page <https://docs.djangoproject.com/en/1.3/howto/deployment/modwsgi/>`_,
it contains an example *wsgi.py* file.
Django 1.3 users, please consult this `page <https://docs.djangoproject.com/en/1.3/howto/deployment/modwsgi/>`_,
it contains an example :file:`wsgi.py` file.
.. note::
.. note::
You will certainly need more configuration in order to launch
Apache.
mod_python
==========
First, make sure that *mod_python* is installed on your server.
First, make sure that mod_python is installed on your server.
Create a new virtualhost in your Apache configuration and put the
following content inside::
@@ -77,9 +86,9 @@ following content inside::
</Location>
</VirtualHost>
This is just one possible configuration.
This is just one possible configuration.
.. note::
.. note::
You will certainly need more configuration in order to launch
Apache.
@@ -89,17 +98,28 @@ This is just one possible configuration.
Nginx
*****
.. note::
The following instructions are meant to help you get your site up
and running quickly. However it is not possible for the people
contributing documentation to Modoboa to test every single
combination of web server, wsgi server, distribution, etc. So it is
possible that **your** installation of uwsgi or nginx or Apache or
what-have-you works differently. Keep this in mind.
Green Unicorn
=============
`Nginx <http://nginx.org/>`_ is a really fast HTTP server. Associated
with `Green Unicorn <http://gunicorn.org/>`_, it gives you one of the
best setup to serve python/Django applications. Modoboa's
performances are really good with this configuration.
best setups to serve python/Django applications. Modoboa's
performance is really good with this configuration.
To use this setup, first download and install those softwares
(`Install gunicorn <http://gunicorn.org/install.html>`_ and `install
nginx <http://wiki.nginx.org/Install>`_).
Then, use the following sample *gunicorn* configuration (create a new
file named *gunicorn.conf.py* inside Modoboa's root dir)::
To use this setup, first download and install `nginx
<http://wiki.nginx.org/Install>`__ and `gunicorn
<http://gunicorn.org/install.html>`_. Then, use the following sample
gunicorn configuration (create a new file named :file:`gunicorn.conf.py`
inside Modoboa's root dir)::
backlog = 2048
bind = "unix:/var/run/gunicorn/modoboa.sock"
@@ -113,9 +133,9 @@ file named *gunicorn.conf.py* inside Modoboa's root dir)::
To start gunicorn, execute the following commands::
$ cd <modoboa dir>
$ gunicorn_django -c gunicorn.conf.py
$ gunicorn -c gunicorn.conf.py <modoboa dir>.wsgi:application
Now the *nginx* part. Just create a new virtual host and use the
Now the nginx part. Just create a new virtual host and use the
following configuration::
upstream modoboa {
@@ -123,13 +143,19 @@ following configuration::
}
server {
listen 443;
listen 443 ssl;
ssl on;
keepalive_timeout 70;
server_name <host fqdn>;
root <modoboa's root dir>;
access_log /var/log/nginx/<host fqdn>.access.log;
error_log /var/log/nginx/<host fqdn>.error.log;
ssl_certificate <ssl certificate for your site>;
ssl_certificate_key <ssl certificate key for your site>;
location /sitestatic/ {
autoindex on;
}
@@ -143,10 +169,165 @@ following configuration::
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_set_header X-Forwarded-Protocol ssl;
proxy_pass http://modoboa;
proxy_pass http://modoboa;
}
}
If you do not plan to use SSL then change the listen directive to
``listen 80;`` and delete each of the following directives::
ssl on;
keepalive_timeout 70;
ssl_certificate <ssl certificate for your site>;
ssl_certificate_key <ssl certificate key for your site>;
proxy_set_header X-Forwarded-Protocol ssl;
If you do plan to use SSL, you'll have to generate a certificate and a
key. `This article
<http://wiki.nginx.org/HttpSslModule#Generate_Certificates>`__
contains information about how to do it.
Paste this content to your configuration (replace values between
``<>`` with yours), restart *nginx* and enjoy a really fast
``<>`` with yours), restart nginx and enjoy a really fast
application!
uwsgi
=====
The following setup is meant to get you started quickly. You should
read the documentation of both nginx and uwsgi to understand how to
optimize their configuration for your site.
The Django documentation includes the following warning regarding
uwsgi:
.. warning::
Some distributions, including Debian and Ubuntu, ship an outdated
version of uWSGI that does not conform to the WSGI
specification. Versions prior to 1.2.6 do not call close on the
response object after handling a request. In those cases the
request_finished signal isn’t sent. This can result in idle
connections to database and memcache servers.
Use uwsgi 1.2.6 or newer. If you do not, you *will* run into
problems. Modoboa will fail in obscure ways.
To use this setup, first download and install `nginx
<http://wiki.nginx.org/Install>`__ and `uwsgi
<http://uwsgi-docs.readthedocs.org/en/latest/WSGIquickstart.html>`_.
Here is a sample nginx configuration::
server {
listen 443 ssl;
ssl on;
keepalive_timeout 70;
server_name <host fqdn>;
root <modoboa's settings dir>;
ssl_certificate <ssl certificate for your site>;
ssl_certificate_key <ssl certificate key for your site>;
access_log /var/log/nginx/<host fqdn>.access.log;
error_log /var/log/nginx/<host fqdn>.error.log;
location <modoboa's root url>/sitestatic/ {
autoindex on;
alias <location of sitestatic on your file system>;
}
# Whether or not Modoboa uses a media directory depends on how
# you configured Modoboa. It does not hurt to have this.
location <modoboa's root url>/media/ {
autoindex on;
alias <location of media on your file system>;
}
# This denies access to any file that begins with
# ".ht". Apache's .htaccess and .htpasswd are such files. A
# Modoboa installed from scratch would not contain any such
# files, but you never know what the future holds.
location ~ /\.ht {
deny all;
}
location <modoba's root url>/ {
include uwsgi_params;
uwsgi_pass <uwsgi port>;
uwsgi_param UWSGI_SCRIPT modoboa_server.wsgi:application
uwsgi_param UWSGI_SCHEME https;
}
}
If you do not plan to use SSL then change the listen directive to
``listen 80;`` and delete each of the following directives::
ssl on;
keepalive_timeout 70;
ssl_certificate <ssl certificate for your site>;
ssl_certificate_key <ssl certificate key for your site>;
uwsgi_param UWSGI_SCHEME https;
If you do plan to use SSL, you'll have to generate a certificate and a
key. `This article
<http://wiki.nginx.org/HttpSslModule#Generate_Certificates>`__
contains information about how to do it.
Make sure to replace the ``<...>`` in the sample configuration with
appropriate values. Here are some explanations for the cases that may
not be completely self-explanatory:
``<modoboa's settings dir>``
Where Modoboa's :file:`settings.py` resides. This is also where the
:file:`sitestatic` and :file:`media` directories reside.
``<modoboa's root url>``
This is the URL which will be the root of your Modoboa site at your
domain. For instance, if your Modoboa installation is reachable at
at ``https://foo/modoboa`` then ``<modoboa's root url>`` is
``/modoboa``. In this case you probably also have to set the
``alias`` directives to point to where Modoboa's sitestatic and
media directories are because otherwise nginx won't be able to find
them.
If Modoboa is at the root of your domain, then ``<modoboa root
url>`` is an empty string and can be deleted from the configuration
above. In this case, you probably do not need the ``alias``
directives.
``<uwsgi port>``
The location where uwsig is listening. It could be a unix domain
socket or an address:port combination. Ubuntu configures uwsgi so
that the port is::
unix:/run/uwsgi/app/<app name>/socket
where ``<app name>`` is the name of the application.
Your uwsgi configuration should be::
[uwsgi]
# Not needed when using uwsgi from pip
# plugins = python
chdir = <modoboa's top dir>
module = <name>.wsgi:application
master = true
harakiri = 30
sharedarea = 4
processes = 4
vhost = true
no-default-app = true
The plugins directive should be turned on if you use a uwsgi
installation that requires it. If uwsgi was installed from pip, it
does not require it. In the configuration above:
``<modoboa's top dir>``
The directory where :file:`manage.py` resides. This directory is the
parent of ``<modoboa's settings dir>``
``<name>``
The name that you passed to ``modoboa-admin.py deploy`` when you
created your Modoboa instance.
+8 -2
View File
@@ -55,8 +55,8 @@ class AliasesMap(MapFile):
class DomainAliasesMailboxesMap(MapFile):
filename = 'sql-domain-aliases-mailboxes.cf'
mysql = "SELECT DISTINCT concat(mb.address, '@', dom.name) FROM admin_alias al INNER JOIN admin_domain dom ON dom.id=al.domain_id INNER JOIN admin_domainalias domal ON domal.target_id=dom.id INNER JOIN (admin_alias_mboxes almb, admin_mailbox mb) ON (almb.alias_id=al.id AND almb.mailbox_id=mb.id) WHERE domal.name='%d' AND domal.enabled=1 AND (al.address='%u' OR mb.address='%u')"
postgres = "SELECT DISTINCT mb.address || '@' || dom.name FROM admin_alias al INNER JOIN admin_domain dom ON dom.id=al.domain_id INNER JOIN admin_domainalias domal ON domal.target_id=dom.id INNER JOIN admin_alias_mboxes almb ON almb.alias_id=al.id INNER JOIN admin_mailbox mb ON almb.mailbox_id=mb.id WHERE domal.name='%d' AND domal.enabled AND (al.address='%u' OR mb.address='%u')"
mysql = "(SELECT concat(mb.address, '@', dom.name) FROM admin_domainalias domal INNER JOIN admin_domain dom ON domal.target_id=dom.id INNER JOIN admin_mailbox mb ON mb.domain_id=dom.id WHERE domal.name='%d' AND dom.enabled=1 AND mb.address='%u') UNION (SELECT concat(al.address, '@', dom.name) FROM admin_domainalias domal INNER JOIN admin_domain dom ON domal.target_id=dom.id INNER JOIN admin_alias al ON al.domain_id=dom.id WHERE domal.name='%d' AND dom.enabled=1 AND al.address='%u')"
postgres = "(SELECT mb.address || '@' || dom.name FROM admin_domainalias domal INNER JOIN admin_domain dom ON domal.target_id=dom.id INNER JOIN admin_mailbox mb ON mb.domain_id=dom.id WHERE domal.name='%d' AND dom.enabled AND mb.address='%u') UNION (SELECT al.address || '@' || dom.name FROM admin_domainalias domal INNER JOIN admin_domain dom ON domal.target_id=dom.id INNER JOIN admin_alias al ON al.domain_id=dom.id WHERE domal.name='%d' AND dom.enabled AND al.address='%u')"
class CatchallAliasesMap(MapFile):
@@ -65,6 +65,12 @@ class CatchallAliasesMap(MapFile):
postgres = "(SELECT mb.address || '@' || dom.name FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id WHERE mb.id IN (SELECT al_mb.mailbox_id FROM admin_alias al INNER JOIN admin_domain dom ON al.domain_id=dom.id INNER JOIN admin_alias_mboxes al_mb ON al.id=al_mb.alias_id WHERE al.enabled AND al.address='*' AND dom.name='%d' AND dom.enabled)) UNION (SELECT al.extmboxes FROM admin_alias al INNER JOIN admin_domain dom ON al.domain_id=dom.id WHERE al.enabled AND al.extmboxes<>'' AND al.address='*' AND dom.name='%d' AND dom.enabled)"
class MaintainMap(MapFile):
filename = 'sql-maintain.cf'
mysql = "SELECT '450 Requested mail action not taken: mailbox unavailable' FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN admin_mailboxoperation mbop ON mbop.mailbox_id=mb.id WHERE dom.name='%d' AND mb.address='%u' LIMIT 1"
postgres = "SELECT '450 Requested mail action not taken: mailbox unavailable' FROM admin_mailbox mb INNER JOIN admin_domain dom ON mb.domain_id=dom.id INNER JOIN admin_mailboxoperation mbop ON mbop.mailbox_id=mb.id WHERE dom.name='%d' AND mb.address='%u' LIMIT 1"
class TransportMap(MapFile):
category = "autoreply"
filename = 'sql-transport.cf'
+2 -2
View File
@@ -338,9 +338,9 @@ class User(AbstractBaseUser, PermissionsMixin):
events.raiseEvent("AccountImported", user, self, row[7:])
def to_csv(self, csvwriter):
row = ["account", self.username.encode("utf-8"), self.password,
row = ["account", self.username.encode("utf-8"), self.password.encode("utf-8"),
self.first_name.encode("utf-8"), self.last_name.encode("utf-8"),
self.is_active, self.group, self.email]
self.is_active, self.group, self.email.encode("utf-8")]
row += events.raiseQueryEvent("AccountExported", self)
csvwriter.writerow(row)
+48
View File
@@ -0,0 +1,48 @@
import factory
from django.contrib.auth.models import Group
from . import models
class GroupFactory(factory.DjangoModelFactory):
FACTORY_FOR = Group
name = 'DefaultGroup'
class MailboxFactory(factory.DjangoModelFactory):
FACTORY_FOR = models.Mailbox
quota = 0
use_domain_quota = True
class UserFactory(factory.DjangoModelFactory):
FACTORY_FOR = models.User
email = factory.LazyAttribute(lambda a: a.username)
password = '{PLAIN}toto'
mailbox = factory.RelatedFactory(
MailboxFactory, 'user',
address=factory.LazyAttribute(
lambda m: m.user.email.replace('@%s' % m.domain.name, '')
)
)
@factory.post_generation
def groups(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return
if extracted:
# A list of groups were passed in, use them
for group in extracted:
self.groups.add(Group.objects.get(name=group))
class DomainFactory(factory.DjangoModelFactory):
FACTORY_FOR = models.Domain
quota = 10
enabled = True
+40 -13
View File
@@ -4,6 +4,7 @@ from django.contrib.auth.models import Group
from django.utils.translation import ugettext as _, ugettext_lazy
from django.http import QueryDict
from modoboa.lib import events, parameters
from modoboa.lib.exceptions import PermDeniedException
from modoboa.lib.emailutils import split_mailbox
from modoboa.lib.permissions import get_account_roles
from modoboa.lib.formutils import (
@@ -78,22 +79,44 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
return cleaned_data
def update_mailbox_quotas(self, domain):
"""Update all quota records associated to this domain
This method must be called only when a domain gets renamed. As
the primary key used for a quota is an email address, rename a
domain will change all associated email addresses, so it will
change the primary keys used for quotas. The consequence is we
can't issue regular UPDATE queries using the .save() method of
a Quota instance (it will trigger an INSERT as the primary key
has changed).
So, we use this ugly hack to bypass this behaviour. It is not
perfomant at all as it will generate one query per quota
record to update.
"""
for q in Quota.objects.filter(username__contains="@%s" % self.oldname).values('username'):
username = q['username'].replace('@%s' % self.oldname, '@%s' % domain.name)
Quota.objects.filter(username=q['username']).update(username=username)
def save(self, user, commit=True):
"""Custom save method
Updating a domain may have consequences on other objects
(domain alias, mailbox, quota). The most tricky part concerns
quotas update.
"""
d = super(DomainFormGeneral, self).save(commit=False)
if commit:
old_mail_homes = None
hm = parameters.get_admin("HANDLE_MAILBOXES", raise_error=False)
if hm == "yes":
if self.oldname is not None and d.name != self.oldname:
for q in Quota.objects.filter(username__contains="@%s" % self.oldname):
q.username = q.username.replace('@%s' % self.oldname, '@%s' % d.name)
q.save()
old_mail_homes = dict((mb.id, mb.mail_home) for mb in d.mailbox_set.all())
if self.oldname is not None and d.name != self.oldname:
d.name = self.oldname
old_mail_homes = \
dict((mb.id, mb.mail_home) for mb in d.mailbox_set.all())
d.name = self.cleaned_data['name']
d.save()
Mailbox.objects.filter(domain=d, use_domain_quota=True).update(quota=d.quota)
if old_mail_homes is not None:
for mb in d.mailbox_set.all():
mb.rename_dir(old_mail_homes[mb.id])
Mailbox.objects.filter(domain=d, use_domain_quota=True) \
.update(quota=d.quota)
for k, v in self.cleaned_data.iteritems():
if not k.startswith("aliases"):
continue
@@ -113,6 +136,12 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
if not len(filter(lambda name: self.cleaned_data[name] == dalias.name,
self.cleaned_data.keys())):
dalias.delete()
if old_mail_homes is not None:
self.update_mailbox_quotas(d)
for mb in d.mailbox_set.all():
mb.rename_dir(old_mail_homes[mb.id])
return d
@@ -278,8 +307,6 @@ class AliasForm(forms.ModelForm, DynamicForm):
except Domain.DoesNotExist:
domain = None
if domain is not None:
if not self.user.can_access(domain):
raise PermDeniedException(v)
try:
rcpt = Alias.objects.get(domain=domain, address=local_part)
if rcpt.full_address == self.cleaned_data["email"]:
@@ -0,0 +1,92 @@
import os
import logging
from optparse import make_option
from django.core.management.base import BaseCommand
from modoboa.lib import parameters
from modoboa.lib.sysutils import exec_cmd
from modoboa.admin import AdminConsole
from modoboa.admin.exceptions import AdminError
from modoboa.admin.models import MailboxOperation
class OperationError(Exception):
pass
class Command(BaseCommand):
help = 'Handles rename and delete operations on mailboxes'
option_list = BaseCommand.option_list + (
make_option(
'--pidfile', default='/tmp/handle_mailbox_operations.pid',
help='Path to the file that will contain the PID of this process'
),
)
def __init__(self, *args, **kwargs):
super(Command, self).__init__(*args, **kwargs)
self.logger = logging.getLogger('modoboa.admin')
def rename_mailbox(self, operation):
if not os.path.exists(operation.argument):
return
new_mail_home = operation.mailbox.mail_home
dirname = os.path.dirname(new_mail_home)
if not os.path.exists(dirname):
try:
os.makedirs(dirname)
except os.error as e:
raise OperationError(str(e))
code, output = exec_cmd(
"mv %s %s" % (operation.argument, new_mail_home)
)
if code:
raise OperationError(output)
def delete_mailbox(self, operation):
if not os.path.exists(operation.argument):
return
code, output = exec_cmd(
"rm -r %s" % operation.argument
)
if code:
raise OperationError(output)
def check_pidfile(self, path):
"""Check if this command is already running
:param str path: path to the file containing the PID
:return: a boolean, True means we can go further
"""
if os.path.exists(path):
with open(path) as fp:
pid = fp.read().strip()
code, output = exec_cmd(
"grep handle_mailbox_operations /proc/%s/cmdline" % pid
)
if not code:
return False
with open(path, 'w') as fp:
print >> fp, os.getpid()
return True
def handle(self, *args, **options):
AdminConsole().load()
if parameters.get_admin("HANDLE_MAILBOXES") == 'no':
return
if not self.check_pidfile(options['pidfile']):
return
for ope in MailboxOperation.objects.all():
try:
f = getattr(self, '%s_mailbox' % ope.type)
except AttributeError:
continue
try:
f(ope)
except (OperationError, AdminError) as e:
self.logger.critical('%s failed (reason: %s)',
ope, str(e).encode('utf-8'))
else:
self.logger.info('%s succeed', ope)
ope.delete()
os.unlink(options['pidfile'])
@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'MailboxOperation'
db.create_table(u'admin_mailboxoperation', (
(u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('mailbox', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['admin.Mailbox'], null=True, blank=True)),
('type', self.gf('django.db.models.fields.CharField')(max_length=20)),
('argument', self.gf('django.db.models.fields.TextField')()),
))
db.send_create_signal(u'admin', ['MailboxOperation'])
def backwards(self, orm):
# Deleting model 'MailboxOperation'
db.delete_table(u'admin_mailboxoperation')
models = {
u'admin.alias': {
'Meta': {'ordering': "['domain__name', 'address']", 'unique_together': "(('address', 'domain'),)", 'object_name': 'Alias'},
'address': ('django.db.models.fields.CharField', [], {'max_length': '254'}),
'aliases': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['admin.Alias']", 'symmetrical': 'False'}),
'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.ObjectDates']"}),
'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.Domain']"}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'extmboxes': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'mboxes': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['admin.Mailbox']", 'symmetrical': 'False'})
},
u'admin.domain': {
'Meta': {'ordering': "['name']", 'object_name': 'Domain'},
'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.ObjectDates']"}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}),
'quota': ('django.db.models.fields.IntegerField', [], {})
},
u'admin.domainalias': {
'Meta': {'object_name': 'DomainAlias'},
'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.ObjectDates']"}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}),
'target': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.Domain']"})
},
u'admin.extension': {
'Meta': {'object_name': 'Extension'},
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '150'})
},
u'admin.mailbox': {
'Meta': {'object_name': 'Mailbox'},
'address': ('django.db.models.fields.CharField', [], {'max_length': '252'}),
'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.ObjectDates']"}),
'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.Domain']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'quota': ('django.db.models.fields.PositiveIntegerField', [], {}),
'use_domain_quota': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.User']"})
},
u'admin.mailboxoperation': {
'Meta': {'object_name': 'MailboxOperation'},
'argument': ('django.db.models.fields.TextField', [], {}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'mailbox': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.Mailbox']", 'null': 'True', 'blank': 'True'}),
'type': ('django.db.models.fields.CharField', [], {'max_length': '20'})
},
u'admin.objectaccess': {
'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'},
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.User']"})
},
u'admin.objectdates': {
'Meta': {'object_name': 'ObjectDates'},
'creation': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'last_modification': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'})
},
u'admin.quota': {
'Meta': {'object_name': 'Quota'},
'bytes': ('django.db.models.fields.BigIntegerField', [], {'default': '0'}),
'mbox': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'quota_value'", 'unique': 'True', 'null': 'True', 'to': u"orm['admin.Mailbox']"}),
'messages': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'username': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'primary_key': 'True'})
},
u'admin.user': {
'Meta': {'ordering': "['username']", 'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_local': ('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': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'})
},
u'auth.group': {
'Meta': {'object_name': 'Group'},
u'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': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
u'auth.permission': {
'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
u'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'}),
u'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'})
}
}
complete_apps = ['admin']
+1 -1
View File
@@ -189,7 +189,7 @@ class Alias(DatesAware):
self.save(int_rcpts, ext_rcpts, creator=user)
def to_csv(self, csvwriter):
row = [self.type, self.full_address, self.enabled]
row = [self.type, self.full_address.encode("utf-8"), self.enabled]
row += self.get_recipients()
csvwriter.writerow(row)
+2 -5
View File
@@ -7,13 +7,14 @@ from modoboa.lib import events, parameters
from modoboa.core.models import ObjectAccess
from modoboa.extensions.admin.exceptions import AdminError
from .base import DatesAware
from .mailbox import MailboxOperation
class DomainManager(Manager):
def get_for_admin(self, admin):
"""Return the domains belonging to this admin
The result is a ``QuerySet`` object, so this function can be used
to fill ``ModelChoiceField`` objects.
"""
@@ -106,10 +107,6 @@ class Domain(DatesAware):
Quota.objects.filter(username__contains='@%s' % self.name).delete()
events.raiseEvent("DeleteMailbox", self.mailbox_set.all())
ungrant_access_to_objects(self.mailbox_set.all())
hm = parameters.get_admin("HANDLE_MAILBOXES", raise_error=False)
if hm == "yes" and not keepdir:
for mb in self.mailbox_set.all():
mb.delete_dir()
if self.alias_set.count():
events.raiseEvent("MailboxAliasDelete", self.alias_set.all())
ungrant_access_to_objects(self.alias_set.all())
+33 -20
View File
@@ -1,4 +1,5 @@
import os
import pwd
import reversion
from django.db import models
from django.db.models import Q
@@ -87,15 +88,27 @@ class Mailbox(DatesAware):
@property
def mail_home(self):
"""
"""Retrieve the home directory of this mailbox.
The home directory refers to the place on the file system
where the mailbox data is stored.
We ask dovecot to give us this information because there are
several patterns to understand and we don't want to implement
them.
"""
hm = parameters.get_admin("HANDLE_MAILBOXES", raise_error=False)
if hm is None or hm == "no":
return None
if self.__mail_home is None:
code, output = exec_cmd("doveadm user %s -f home" % self.full_address,
sudo_user=parameters.get_admin("MAILBOXES_OWNER"))
curuser = pwd.getpwuid(os.getuid()).pw_name
mbowner = parameters.get_admin("MAILBOXES_OWNER")
options = {}
if curuser != mbowner:
options['sudo_user'] = mbowner
code, output = exec_cmd(
"doveadm user %s -f home" % self.full_address, **options
)
if code:
raise AdminError(_("Failed to retrieve mailbox location (%s)" % output))
self.__mail_home = output.strip()
@@ -105,15 +118,9 @@ class Mailbox(DatesAware):
hm = parameters.get_admin("HANDLE_MAILBOXES", raise_error=False)
if hm is None or hm == "no":
return
self.__mail_home = None
if not os.path.exists(old_mail_home):
return
code, output = exec_cmd(
"mv %s %s" % (old_mail_home, self.mail_home),
sudo_user=parameters.get_admin("MAILBOXES_OWNER")
MailboxOperation.objects.create(
mailbox=self, type='rename', argument=old_mail_home
)
if code:
raise AdminError(_("Failed to rename mailbox: %s" % output))
def rename(self, address, domain):
"""Rename the mailbox
@@ -125,21 +132,14 @@ class Mailbox(DatesAware):
qs = Quota.objects.filter(username=self.full_address)
self.address = address
self.domain = domain
self.rename_dir(old_mail_home)
qs.update(username=self.full_address)
self.rename_dir(old_mail_home)
def delete_dir(self):
hm = parameters.get_admin("HANDLE_MAILBOXES", raise_error=False)
if hm is None or hm == "no":
return
if not os.path.exists(self.mail_home):
return
code, output = exec_cmd(
"rm -r %s" % self.mail_home,
sudo_user=parameters.get_admin("MAILBOXES_OWNER")
)
if code:
raise AdminError(_("Failed to remove mailbox: %s" % output))
MailboxOperation.objects.create(type='delete', argument=self.mail_home)
def set_quota(self, value=None, override_rules=False):
"""Set or update quota's value for this mailbox.
@@ -250,3 +250,16 @@ class Quota(models.Model):
class Meta:
app_label = 'admin'
class MailboxOperation(models.Model):
mailbox = models.ForeignKey(Mailbox, blank=True, null=True)
type = models.CharField(
max_length=20, choices=(('rename', 'rename'), ('delete', 'delete'))
)
argument = models.TextField()
def __str__(self):
if self.type == 'rename':
return 'Rename %s -> %s' % (self.argument, self.mailbox.mail_home)
return 'Delete %s' % self.argument
@@ -22,6 +22,10 @@ table .progress {
margin-bottom: 0 !important;
}
table tbody tr:hover {
background-color: #F9F9F9;
}
.search {
width: 85%;
-webkit-border-radius:14px;-moz-border-radius:14px;border-radius:14px;
@@ -114,7 +114,7 @@ Domains.prototype = {
: gettext("This operation will remove all data associated to this domain, excepting accounts.");
$("a[name=deldomain]").confirm({
question: gettext("Delete this domain?"),
question: function() { return this.$element.attr('title'); },
method: "POST",
warning: warnmsg,
checkboxes: deloptions,
@@ -240,23 +240,13 @@ Identities.prototype = {
$("a.filter").click($.proxy(this.filter_by_tag, this));
$("a[name=delaccount]").confirm({
question: gettext("Delete this account?"),
question: function() { return this.$element.attr('title'); },
method: "POST",
checkboxes: deloptions,
success_cb: $.proxy(this.reload_listing, this)
});
$("a[name=deldlist]").confirm({
question: gettext("Delete this list?"),
method: "DELETE",
success_cb: $.proxy(this.reload_listing, this)
});
$("a[name=delforward]").confirm({
question: gettext("Delete this forward?"),
method: "DELETE",
success_cb: $.proxy(this.reload_listing, this)
});
$("a[name=delalias]").confirm({
question: gettext("Delete this alias?"),
question: function() { return this.$element.attr('title'); },
method: "DELETE",
success_cb: $.proxy(this.reload_listing, this)
});
@@ -119,7 +119,7 @@ def domain_actions(user, domid):
actions.append({
"name": "deldomain",
"url": reverse("modoboa.extensions.admin.views.domain.deldomain", args=[domid]),
"title": _("Delete the domain"),
"title": _("Delete %s?" % domain.name),
"img": "icon-trash"
})
return render_actions(actions)
@@ -135,30 +135,15 @@ def identity_actions(user, ident):
{"name": "delaccount",
"url": reverse("modoboa.extensions.admin.views.identity.delaccount", args=[objid]),
"img": "icon-trash",
"title": _("Delete this account")},
"title": _("Delete %s?" % ident.username)},
]
else:
if ident.get_recipients_count() >= 2:
actions = [
{"name": "deldlist",
"url": reverse("modoboa.extensions.admin.views.alias.deldlist") + "?selection=%s" % objid,
"img": "icon-trash",
"title": _("Delete this distribution list")},
]
elif ident.extmboxes != "":
actions = [
{"name": "delforward",
"url": reverse("modoboa.extensions.admin.views.alias.delforward") + "?selection=%s" % objid,
"img": "icon-trash",
"title": _("Delete this forward")},
]
else:
actions = [
{"name": "delalias",
"url": reverse("modoboa.extensions.admin.views.alias.delalias") + "?selection=%s" % objid,
"img": "icon-trash",
"title": _("Delete this alias")},
]
actions = [
{"name": "delalias",
"url": reverse("modoboa.admin.views.delalias") + "?selection=%s" % objid,
"img": "icon-trash",
"title": _("Delete %s?" % ident.full_address)},
]
return render_actions(actions)
+73 -24
View File
@@ -19,10 +19,15 @@ from modoboa.lib import parameters
from modoboa.lib.tests import ModoTestCase
from modoboa.extensions.admin.lib import *
from modoboa.extensions.admin.models import *
from . import factories
class DomainTestCase(ModoTestCase):
fixtures = ["initial_users.json", "test_content.json"]
fixtures = ["initial_users.json"]
def setUp(self):
super(DomainTestCase, self).setUp()
self.dom = factories.DomainFactory(name='test.com')
def test_create(self):
"""Test the creation of a domain
@@ -78,7 +83,10 @@ class DomainTestCase(ModoTestCase):
values = {
"name": "pouet.com", "quota": 100, "enabled": True
}
self.check_ajax_post(reverse("modoboa.admin.views.editdomain", args=[1]), values)
self.check_ajax_post(
reverse("modoboa.admin.views.editdomain", args=[self.dom.id]),
values
)
dom = Domain.objects.get(name="pouet.com")
self.assertEqual(dom.name, "pouet.com")
self.assertTrue(dom.enabled)
@@ -86,13 +94,17 @@ class DomainTestCase(ModoTestCase):
def test_delete(self):
"""Test the removal of a domain
"""
self.check_ajax_get(reverse("modoboa.admin.views.deldomain", args=[1]), {})
self.check_ajax_get(reverse("modoboa.admin.views.deldomain", args=[self.dom.id]), {})
with self.assertRaises(Domain.DoesNotExist):
Domain.objects.get(pk=1)
class DomainAliasTestCase(ModoTestCase):
fixtures = ["initial_users.json", "test_content.json"]
fixtures = ['initial_users.json']
def setUp(self):
super(DomainAliasTestCase, self).setUp()
self.dom = factories.DomainFactory(name='test.com')
def test_model(self):
dom = Domain.objects.get(name="test.com")
@@ -111,16 +123,23 @@ class DomainAliasTestCase(ModoTestCase):
dom = Domain.objects.get(name="test.com")
values = dict(name=dom.name, quota=dom.quota, enabled=dom.enabled,
aliases="domalias.net", aliases_1="domalias.com")
self.check_ajax_post(reverse("modoboa.admin.views.editdomain", args=[1]), values)
self.check_ajax_post(reverse("modoboa.admin.views.editdomain", args=[dom.id]), values)
self.assertEqual(dom.domainalias_set.count(), 2)
del values["aliases_1"]
self.check_ajax_post(reverse("modoboa.admin.views.editdomain", args=[1]), values)
self.check_ajax_post(reverse("modoboa.admin.views.editdomain", args=[dom.id]), values)
self.assertEqual(dom.domainalias_set.count(), 1)
class AccountTestCase(ModoTestCase):
fixtures = ["initial_users.json", "test_content.json",]
fixtures = ["initial_users.json"]
def setUp(self):
super(AccountTestCase, self).setUp()
self.dom = factories.DomainFactory(name="test.com")
self.account = factories.UserFactory(
username="user@test.com", groups=('SimpleUsers',), mailbox__domain=self.dom
)
def test_crud(self):
values = dict(username="tester@test.com", first_name="Tester", last_name="Toto",
@@ -132,14 +151,14 @@ class AccountTestCase(ModoTestCase):
account = User.objects.get(username="tester@test.com")
mb = account.mailbox_set.all()[0]
self.assertEqual(mb.full_address, "tester@test.com")
self.assertEqual(mb.quota, 100)
self.assertEqual(mb.quota, 10)
self.assertEqual(mb.enabled, True)
self.assertEqual(mb.quota_value.username, "tester@test.com")
self.assertEqual(account.username, mb.full_address)
self.assertTrue(account.check_password("toto"))
self.assertEqual(account.first_name, "Tester")
self.assertEqual(account.last_name, "Toto")
self.assertEqual(mb.domain.mailbox_count, 4)
self.assertEqual(mb.domain.mailbox_count, 2)
values["username"] = "pouet@test.com"
self.check_ajax_post(reverse("modoboa.admin.views.editaccount", args=[account.id]), values)
@@ -173,7 +192,17 @@ class AccountTestCase(ModoTestCase):
class AliasTestCase(ModoTestCase):
fixtures = ["initial_users.json", "test_content.json"]
fixtures = ["initial_users.json"]
def setUp(self):
super(AliasTestCase, self).setUp()
dom = factories.DomainFactory(name="test.com")
factories.UserFactory.create(
username="admin@test.com", groups=('DomainAdmins',), mailbox__domain=dom
)
factories.UserFactory.create(
username="user@test.com", groups=('SimpleUsers',), mailbox__domain=dom
)
def test_alias(self):
user = User.objects.get(username="user@test.com")
@@ -208,7 +237,7 @@ class AliasTestCase(ModoTestCase):
values)
self.assertEqual(dlist.get_recipients_count(), 2)
self.check_ajax_get(reverse("modoboa.admin.views.deldlist") + "?selection=%d" \
self.check_ajax_get(reverse("modoboa.admin.views.delalias") + "?selection=%d" \
% dlist.id, {})
self.assertRaises(Alias.DoesNotExist, Alias.objects.get,
address="all", domain__name="test.com")
@@ -224,7 +253,7 @@ class AliasTestCase(ModoTestCase):
values)
self.assertEqual(fwd.get_recipients_count(), 1)
self.check_ajax_get(reverse("modoboa.admin.views.delforward") + "?selection=%d" \
self.check_ajax_get(reverse("modoboa.admin.views.delalias") + "?selection=%d" \
% fwd.id, {})
self.assertRaises(Alias.DoesNotExist, Alias.objects.get,
address="forward", domain__name="test.com")
@@ -245,24 +274,28 @@ class AliasTestCase(ModoTestCase):
class PermissionsTestCase(ModoTestCase):
fixtures = ["initial_users.json", "test_content.json"]
fixtures = ["initial_users.json"]
def setUp(self):
"""
FIXME: je n'arrive pas à générer des données de test incluant
déjà les permissions (cf. table ObjectAccess). C'est pour
cette raison que j'assigne le domaine test.com ici...
"""
super(PermissionsTestCase, self).setUp()
self.user = User.objects.get(username="user@test.com")
dom = factories.DomainFactory(name='test.com')
self.user = factories.UserFactory(
username="user@test.com", groups=('SimpleUsers',), mailbox__domain=dom
)
self.values = dict(
username=self.user.username, role="DomainAdmins",
is_active=self.user.is_active, email="user@test.com"
)
self.admin = User.objects.get(username="admin@test.com")
dom = Domain.objects.get(name="test.com")
self.admin = factories.UserFactory(
username="admin@test.com", groups=('DomainAdmins',), mailbox__domain=dom
)
dom.add_admin(self.admin)
dom = factories.DomainFactory(name='test2.com')
factories.UserFactory(
username='user@test2.com', groups=('SimpleUsers',), mailbox__domain=dom
)
def tearDown(self):
self.clt.logout()
@@ -336,15 +369,31 @@ class PermissionsTestCase(ModoTestCase):
self.check_ajax_get(reverse("modoboa.admin.views.delaccount", args=[account.id]), {},
status="ko", respmsg="Permission denied")
def test_domainadmin_dlist_local_domain_not_owned(self):
"""Check if a domain admin can use a local mailbox he can't
access as a recipient in a distribution list"""
values = dict(
email="all@test.com",
recipients="user@test.com",
recipients_1="user@test2.com",
enabled=True
)
self.check_ajax_post(reverse("modoboa.admin.views.newdlist"), values)
class ImportTestCase(ModoTestCase):
fixtures = ["initial_users.json", "test_content.json"]
fixtures = ["initial_users.json"]
def setUp(self):
super(ImportTestCase, self).setUp()
self.admin = User.objects.get(username="admin@test.com")
dom = Domain.objects.get(name="test.com")
dom = factories.DomainFactory(name="test.com")
self.admin = factories.UserFactory.create(
username="admin@test.com", groups=('DomainAdmins',), mailbox__domain=dom
)
dom.add_admin(self.admin)
factories.UserFactory.create(
username="user@test.com", groups=('SimpleUsers',), mailbox__domain=dom
)
def test_domains_import(self):
f = ContentFile(b"""domain; domain1.com; 100; True
-3
View File
@@ -29,10 +29,7 @@ urlpatterns += patterns(
urlpatterns += patterns(
'modoboa.extensions.admin.views.alias',
(r'^distriblists/new/$', 'newdlist'),
(r'^distriblists/delete/$', 'deldlist'),
(r'^forwards/new/$', 'newforward'),
(r'^forwards/delete/$', 'delforward'),
(r'^aliases/new/$', 'newalias'),
(r'^aliases/edit/(?P<alid>\d+)/$', 'editalias'),
+13 -23
View File
@@ -129,35 +129,25 @@ def editalias(request, alid, tplname="common/generic_modal_form.html"):
return render(request, tplname, ctx)
def _del_alias(request, msg, msgs):
@login_required
@permission_required("admin.delete_alias")
@transaction.commit_on_success
def delalias(request):
selection = request.GET["selection"].split(",")
for alid in selection:
alias = Alias.objects.get(pk=alid)
if not request.user.can_access(alias):
raise PermDeniedException
if alias.type == 'dlist':
msg = "Distribution list deleted"
msgs = "Distribution lists deleted"
elif alias.type == 'forward':
msg = "Forward deleted"
msgs = "Forwards deleted"
else:
msg = "Alias deleted"
msgs = "Aliases deleted"
alias.delete()
msg = ungettext(msg, msgs, len(selection))
return ajax_simple_response({"status": "ok", "respmsg": msg})
@login_required
@permission_required("admin.delete_alias")
@transaction.commit_on_success
def delalias(request):
return _del_alias(request, "Alias deleted", "Aliases deleted")
@login_required
@permission_required("admin.delete_alias")
@transaction.commit_on_success
def deldlist(request):
return _del_alias(request, "Distribution list deleted",
"Distribution lists deleted")
@login_required
@permission_required("admin.delete_alias")
@transaction.commit_on_success
def delforward(request):
return _del_alias(request, "Forward deleted", "Forwards deleted")
+11 -8
View File
@@ -9,7 +9,9 @@ from django.contrib.auth.decorators import (
login_required, permission_required, user_passes_test
)
from modoboa.lib import parameters, events
from modoboa.lib.webutils import ajax_simple_response, ajax_response
from modoboa.lib.webutils import (
ajax_simple_response, ajax_response, _render_to_string
)
from modoboa.lib.formutils import CreationWizard
from modoboa.lib.exceptions import PermDeniedException
from modoboa.lib.templatetags.libextras import pagination_bar
@@ -123,6 +125,7 @@ def newdomain(request, tplname="common/wizard_forms.html"):
@login_required
@permission_required("admin.view_domains")
@transaction.commit_on_success
def editdomain(request, dom_id, tplname="admin/editdomainform.html"):
domain = Domain.objects.get(pk=dom_id)
if not request.user.can_access(domain):
@@ -153,15 +156,15 @@ def editdomain(request, dom_id, tplname="admin/editdomainform.html"):
error = str(e)
else:
events.raiseEvent("DomainModified", domain)
return ajax_simple_response({
"status": "ok", "respmsg": _("Domain modified")
})
return ajax_simple_response({
"status": "ok", "respmsg": _("Domain modified")
})
commonctx["tabs"] = form
commonctx["error"] = error
return ajax_response(
request, status="ko", template=tplname, **commonctx
)
return ajax_simple_response({
"status": "ko", "respmsg": error,
"content": _render_to_string(request, tplname, commonctx)
})
commonctx["tabs"] = DomainForm(request.user, instances=instances)
commonctx["domadmins"] = domadmins
+2 -1
View File
@@ -1,5 +1,6 @@
import csv
import cStringIO
from rfc6266 import build_header
from django.http import HttpResponse
from django.shortcuts import render
from django.core.urlresolvers import reverse
@@ -25,7 +26,7 @@ def _export(content, filename):
resp = HttpResponse(content)
resp["Content-Type"] = "text/csv"
resp["Content-Length"] = len(content)
resp["Content-Disposition"] = 'attachment; filename="%s"' % filename
resp["Content-Disposition"] = build_header(filename)
return resp
+21 -13
View File
@@ -21,17 +21,25 @@ from .lib import selfservice, AMrelease
from .sql_listing import SQLlisting, SQLemail, get_wrapper
def __get_current_url(request):
def __back_to_listing(request):
"""Return the current listing URL.
Looks into the user's session and the current request to build the
URL.
:param request: a ``Request`` object
:return: a string
"""
url = "listing"
params = []
if "page" in request.session:
res = "listing?page=%s" % request.session["page"]
else:
res = ""
params = "&".join(["%s=%s" % (p, request.session[p])
for p in ["criteria", "pattern"]
if p in request.sessions])
if params != "":
res += "?%s" % (params)
return res
params += ["page=%s" % request.session["page"]]
params += ["%s=%s" % (p, request.session[p])
for p in ["criteria", "pattern"] if p in request.session]
if params:
url += "?%s" % ("&".join(params))
return url
def empty_quarantine(request):
@@ -225,7 +233,7 @@ def delete(request, mail_id):
"%(count)d messages deleted successfully",
len(mail_id)) % {"count": len(mail_id)}
return ajax_response(request, respmsg=message,
url=__get_current_url(request))
url=__back_to_listing(request))
def release_selfservice(request, mail_id):
@@ -273,7 +281,7 @@ def release(request, mail_id):
"%(count)d requests sent",
len(mail_id)) % {"count": len(mail_id)}
return ajax_response(request, "ok", respmsg=message,
url=__get_current_url(request))
url=__back_to_listing(request))
else:
msgrcpts = []
wrapper = get_wrapper()
@@ -299,7 +307,7 @@ def release(request, mail_id):
else:
message = error
return ajax_response(request, "ko" if error else "ok", respmsg=message,
url=__get_current_url(request))
url=__back_to_listing(request))
@login_required
+10 -5
View File
@@ -1,12 +1,21 @@
from django.core.urlresolvers import reverse
from modoboa.lib.tests import ExtTestCase
from modoboa.core.models import User
from modoboa.admin import factories
class PermissionsTestCase(ExtTestCase):
fixtures = ["initial_users.json", "test_content.json"]
fixtures = ["initial_users.json"]
name = "limits"
def setUp(self):
super(PermissionsTestCase, self).setUp()
dom = factories.DomainFactory(name='test.com')
self.admin = factories.UserFactory(
username='admin@test.com', groups=('DomainAdmins',), mailbox__domain=dom
)
dom.add_admin(self.admin)
def test_domainadmin_deletes_reseller(self):
"""Check if a domain admin can delete a reseller.
@@ -25,7 +34,3 @@ class PermissionsTestCase(ExtTestCase):
reverse("modoboa.extensions.admin.views.identity.delaccount", args=[account.id]),
{}, status="ko", respmsg="Permission denied"
)
self.check_ajax_get(
reverse("modoboa.extensions.admin.views.identity.delaccount", args=[4]),
{}, status="ko", respmsg="Permission denied"
)
@@ -89,10 +89,22 @@ def onCreateDomain(user, domain):
transport.save()
@events.observe("DomainModified")
def onDomainModified(domain):
if domain.oldname == domain.name:
return
Transport.objects.filter(domain="autoreply.%s" % domain.oldname) \
.update(domain="autoreply.%s" % domain.name)
for al in Alias.objects.filter(full_address__contains="@%s" % domain.oldname):
new_address = al.full_address.replace("@%s" % domain.oldname, "@%s" % domain.name)
al.full_address = new_address
al.autoreply_address = "%s@autoreply.%s" % (new_address, domain.name)
al.save()
@events.observe("DeleteDomain")
def onDeleteDomain(domain):
trans = Transport.objects.get(domain="autoreply.%s" % domain.name)
trans.delete()
Transport.objects.filter(domain="autoreply.%s" % domain.name).delete()
@events.observe("CreateMailbox")
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Changing field 'ARhistoric.sender'
db.alter_column(u'postfix_autoreply_arhistoric', 'sender', self.gf('django.db.models.fields.CharField')(max_length=254))
def backwards(self, orm):
# Changing field 'ARhistoric.sender'
db.alter_column(u'postfix_autoreply_arhistoric', 'sender', self.gf('django.db.models.fields.TextField')())
models = {
u'admin.domain': {
'Meta': {'ordering': "['name']", 'object_name': 'Domain'},
'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.ObjectDates']"}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}),
'quota': ('django.db.models.fields.IntegerField', [], {})
},
u'admin.mailbox': {
'Meta': {'object_name': 'Mailbox'},
'address': ('django.db.models.fields.CharField', [], {'max_length': '252'}),
'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.ObjectDates']"}),
'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.Domain']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'quota': ('django.db.models.fields.PositiveIntegerField', [], {}),
'use_domain_quota': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.User']"})
},
u'admin.objectaccess': {
'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'},
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.User']"})
},
u'admin.objectdates': {
'Meta': {'object_name': 'ObjectDates'},
'creation': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'last_modification': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'})
},
u'admin.user': {
'Meta': {'ordering': "['username']", 'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_local': ('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': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'})
},
u'auth.group': {
'Meta': {'object_name': 'Group'},
u'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': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
u'auth.permission': {
'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
u'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'}),
u'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'})
},
u'postfix_autoreply.alias': {
'Meta': {'object_name': 'Alias'},
'autoreply_address': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'full_address': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
u'postfix_autoreply.arhistoric': {
'Meta': {'object_name': 'ARhistoric'},
'armessage': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['postfix_autoreply.ARmessage']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'last_sent': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'sender': ('django.db.models.fields.CharField', [], {'max_length': '254'})
},
u'postfix_autoreply.armessage': {
'Meta': {'object_name': 'ARmessage'},
'content': ('django.db.models.fields.TextField', [], {}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'mbox': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.Mailbox']"}),
'subject': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'untildate': ('django.db.models.fields.DateTimeField', [], {})
},
u'postfix_autoreply.transport': {
'Meta': {'object_name': 'Transport'},
'domain': ('django.db.models.fields.CharField', [], {'max_length': '300'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'method': ('django.db.models.fields.CharField', [], {'max_length': '255'})
}
}
complete_apps = ['postfix_autoreply']
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding unique constraint on 'ARhistoric', fields ['sender', 'armessage']
db.create_unique(u'postfix_autoreply_arhistoric', ['sender', 'armessage_id'])
def backwards(self, orm):
# Removing unique constraint on 'ARhistoric', fields ['sender', 'armessage']
db.delete_unique(u'postfix_autoreply_arhistoric', ['sender', 'armessage_id'])
models = {
u'admin.domain': {
'Meta': {'ordering': "['name']", 'object_name': 'Domain'},
'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.ObjectDates']"}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}),
'quota': ('django.db.models.fields.IntegerField', [], {})
},
u'admin.mailbox': {
'Meta': {'object_name': 'Mailbox'},
'address': ('django.db.models.fields.CharField', [], {'max_length': '252'}),
'dates': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.ObjectDates']"}),
'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.Domain']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'quota': ('django.db.models.fields.PositiveIntegerField', [], {}),
'use_domain_quota': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.User']"})
},
u'admin.objectaccess': {
'Meta': {'unique_together': "(('user', 'content_type', 'object_id'),)", 'object_name': 'ObjectAccess'},
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.User']"})
},
u'admin.objectdates': {
'Meta': {'object_name': 'ObjectDates'},
'creation': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'last_modification': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'})
},
u'admin.user': {
'Meta': {'ordering': "['username']", 'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '254', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_local': ('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': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '254'})
},
u'auth.group': {
'Meta': {'object_name': 'Group'},
u'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': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
u'auth.permission': {
'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
u'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'}),
u'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'})
},
u'postfix_autoreply.alias': {
'Meta': {'object_name': 'Alias'},
'autoreply_address': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'full_address': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
u'postfix_autoreply.arhistoric': {
'Meta': {'unique_together': "(('armessage', 'sender'),)", 'object_name': 'ARhistoric'},
'armessage': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['postfix_autoreply.ARmessage']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'last_sent': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'sender': ('django.db.models.fields.CharField', [], {'max_length': '254'})
},
u'postfix_autoreply.armessage': {
'Meta': {'object_name': 'ARmessage'},
'content': ('django.db.models.fields.TextField', [], {}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'mbox': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['admin.Mailbox']"}),
'subject': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'untildate': ('django.db.models.fields.DateTimeField', [], {})
},
u'postfix_autoreply.transport': {
'Meta': {'object_name': 'Transport'},
'domain': ('django.db.models.fields.CharField', [], {'max_length': '300'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'method': ('django.db.models.fields.CharField', [], {'max_length': '255'})
}
}
complete_apps = ['postfix_autoreply']
@@ -3,6 +3,7 @@ from django.utils.translation import ugettext_lazy as _
from modoboa.extensions.admin.models import Domain, Mailbox
class Transport(models.Model):
domain = models.CharField(max_length=300)
method = models.CharField(max_length=255)
@@ -27,10 +28,13 @@ class ARmessage(models.Model):
_('enabled'),
help_text=_("Activate/Deactivate your auto reply")
)
untildate = models.DateField()
untildate = models.DateTimeField()
class ARhistoric(models.Model):
armessage = models.ForeignKey(ARmessage)
last_sent = models.DateTimeField(auto_now=True)
sender = models.TextField()
sender = models.CharField(max_length=254)
class Meta:
unique_together = ("armessage", "sender")
+1 -1
View File
@@ -164,7 +164,7 @@ class FilterForm(forms.Form):
for fd in folders:
value = fd["path"] if "path" in fd else fd["name"]
if parentmb:
ret += [(value, fd["name"].replace("%s." % parentmb, ""))]
ret += [(value, fd["name"].replace("%s%s" % (parentmb, imapc.hdelimiter), ""))]
else:
ret += [(value, fd["name"])]
if "sub" in fd:
@@ -178,7 +178,7 @@
value = $this.val();
while (true) {
var id = value.indexOf(".", start);
var id = value.indexOf(options.hdelimiter, start);
if (id == -1) {
break;
}
@@ -322,6 +322,7 @@
$.fn.dynaction.defaults = {
defaultaction: "fileinto",
hdelimiter: '.',
templates: []
};
})(jQuery);
@@ -44,6 +44,7 @@ function changefs(evt) {
function init_filters_list() {
$("a[name=togglestate]").click(toggle_filter_state);
$("a[name*=movefilter_]").click(move_filter);
$("a[name=removefilter]").click(removefilter);
}
/*
@@ -63,7 +64,6 @@ function loadfs(response) {
$("#fsetmenu").remove();
}
$("#menu").after($(response.menu));
$("a[name=removefilter]").click(removefilter);
$("a[name=activatefs]").click(activatefs);
$("a[name=savefs]").click(savefs);
$("a[name=removefs]").click(removefs);
@@ -22,7 +22,8 @@ function filterform_cb() {
templates: cond_templates
}).data('dyncondition');
var $da = $("#action_0").dynaction({
templates: action_templates
templates: action_templates,
hdelimiter: '{{ hdelimiter }}'
}).data('dynaction');
$("input[name=match_type]").click(function(e) {
+22 -9
View File
@@ -1,5 +1,6 @@
# coding: utf-8
from sievelib.managesieve import Error
from rfc6266 import build_header
from django.shortcuts import render
from django.http import HttpResponse
from django.template.loader import render_to_string
@@ -23,6 +24,8 @@ from templatetags.sfilters_tags import fset_menu
@login_required
@needs_mailbox()
def index(request, tplname="sievefilters/index.html"):
from modoboa.extensions.webmail.imaputils import get_imapconnector
try:
sc = SieveClient(user=request.user.username,
password=request.session["password"])
@@ -44,6 +47,7 @@ def index(request, tplname="sievefilters/index.html"):
"active_script": active_script,
"default_script": default_script,
"scripts": sorted(scripts),
"hdelimiter": get_imapconnector(request).hdelimiter
})
@@ -107,14 +111,19 @@ def submitfilter(request, setname, okmsg, tplname, tplctx, update=False, sc=None
if match_type == "all":
match_type = "anyof"
conditions = [("true",)]
fltname = form.cleaned_data["name"].encode("utf-8")
if not update:
fset.addfilter(form.cleaned_data["name"], conditions, actions,
fset.addfilter(fltname, conditions, actions,
match_type)
else:
fset.updatefilter(request.POST["oldname"],
form.cleaned_data["name"], conditions, actions,
match_type)
sc.pushscript(fset.name, str(fset))
oldname = request.POST["oldname"].encode("utf-8")
fset.updatefilter(
oldname, fltname, conditions, actions, match_type
)
try:
sc.pushscript(fset.name, str(fset))
except SieveClientError as e:
return ajax_response(request, "ko", respmsg=str(e))
return ajax_response(request, respmsg=okmsg, ajaxnav=True)
tplctx = build_filter_ctx(tplctx, form)
return ajax_response(request, status="ko", template=tplname, **tplctx)
@@ -159,6 +168,8 @@ def editfilter(request, setname, fname, tplname="sievefilters/filter.html"):
update=True, sc=sc)
fset = sc.getscript(setname, format="fset")
if type(fname) is unicode:
fname = fname.encode("utf-8")
f = fset.getfilter(fname)
form = build_filter_form_from_filter(request, fname, f)
ctx = build_filter_ctx(ctx, form)
@@ -174,7 +185,7 @@ def removefilter(request, setname, fname):
sc = SieveClient(user=request.user.username,
password=request.session["password"])
fset = sc.getscript(setname, format="fset")
if fset.removefilter(fname):
if fset.removefilter(fname.encode("utf-8")):
sc.pushscript(fset.name, str(fset))
return ajax_response(request, respmsg=_("Filter removed"))
return ajax_response(request, "ko", respmsg=_("Failed to remove filter"))
@@ -270,9 +281,9 @@ def download_filters_set(request, name):
return ajax_response(request, "ko", respmsg=str(e))
resp = HttpResponse(script)
resp["Content-Type"] = "text/plain"
resp["Content-Type"] = "text/plain; charset=utf-8"
resp["Content-Length"] = len(script)
resp["Content-Disposition"] = 'attachment; filename="%s.txt"' % name
resp["Content-Disposition"] = build_header('%s.txt' % name)
return resp
@@ -281,6 +292,8 @@ def download_filters_set(request, name):
def toggle_filter_state(request, setname, fname):
sc = SieveClient(user=request.user.username,
password=request.session["password"])
if type(fname) is unicode:
fname = fname.encode("utf-8")
try:
fset = sc.getscript(setname, format="fset")
if fset.is_filter_disabled(fname):
@@ -309,7 +322,7 @@ def move_filter(request, setname, fname, direction):
password=request.session["password"])
try:
fset = sc.getscript(setname, format="fset")
fset.movefilter(fname, direction)
fset.movefilter(fname.encode("utf-8"), direction)
sc.pushscript(setname, str(fset))
except (SieveClientError), e:
return ajax_response(request, "ko", respmsg=str(e))
+29 -12
View File
@@ -10,11 +10,21 @@ retrieve multiple attributes of a message.
This simple module tries to fix that *problem*.
"""
class ParseError(Exception):
pass
class Token(object):
def __init__(self, value):
self.value = value
class Literal(Token):
def __init__(self, value):
super(Literal, self).__init__(value)
self.next_token_len = int(self.value[1:-1])
def parse_next_token(buf):
"""Look for the next *token*
@@ -26,9 +36,11 @@ def parse_next_token(buf):
:return: the position of the token's last caracter into ``buf``
"""
end = -1
klass = Token
if buf[0] == '{':
# Literal
end = buf.find('}')
klass = Literal
elif buf[0] == '"':
# quoted
end = buf.find('"', 1)
@@ -39,7 +51,9 @@ def parse_next_token(buf):
break
if end == -1:
raise ParseError("End of buffer reached while looking for a token end")
return end
end += 1
token = klass(buf[:end])
return token, end
def parse_bodystructure(buf, depth=0, prefix=""):
@@ -89,9 +103,13 @@ def parse_bodystructure(buf, depth=0, prefix=""):
pos += 1
continue
nb_bodystruct = 0
end = parse_next_token(buf[pos:])
ret.append(buf[pos:pos + end + 1].strip('"'))
pos += end + 1
token, end = parse_next_token(buf[pos:])
pos += end
if isinstance(token, Literal):
ret.append(buf[pos:pos + token.next_token_len].strip('"'))
pos += token.next_token_len
else:
ret.append(token.value.strip('"'))
raise ParseError("End of buffer reached while looking for a BODY/BODYSTRUCTURE end")
@@ -155,11 +173,10 @@ def parse_fetch_response(data):
end = pos + 1
break
else:
end = parse_next_token(response)
if response[0] == '{':
datalen = int(response[1:end])
response = response[end + 1:]
end = datalen
token, end = parse_next_token(response)
if isinstance(token, Literal):
response = response[end:]
end = token.next_token_len
msgdef[cmdname] = response[:end]
response = response[end + 1:]
@@ -201,8 +218,8 @@ if __name__ == "__main__":
# (' (("Free Haut Debit" NIL "inscription" "freetelecom.fr")) (("Free Haut Debit" NIL "inscription" "freetelecom.fr")) ((NIL NIL "hautdebit" "freetelecom.fr")) ((NIL NIL "nguyen.antoine" "wanadoo.fr")) NIL NIL NIL "<20061213193125.9DA0919AC@dgroup2-2.proxad.net>") ("text" "plain" ("charset" "iso-8859-1") NIL NIL "8bit" 1428 38 NIL ("inline" NIL) NIL NIL) 76 NIL ("inline" ("filename*" "ISO-8859-1\'\'%5B%49%4E%53%43%52%49%50%54%49%4F%4E%5D%20%52%E9%63%E9%70%74%69%6F%6E%20%64%65%20%76%6F%74%72%65%20%64%6F%73%73%69%65%72%20%64%27%69%6E%73%63%72%69%70%74%69%6F%6E%20%46%72%65%65%20%48%61%75%74%20%44%E9%62%69%74")) NIL NIL) "mixed" ("boundary" "------------040706080908000209030901") NIL NIL NIL) BODY[HEADER.FIELDS (DATE FROM TO CC SUBJECT)] {266}',
# 'Date: Tue, 19 Dec 2006 19:50:13 +0100\r\nFrom: Antoine Nguyen <nguyen.antoine@wanadoo.fr>\r\nTo: Antoine Nguyen <tonio@koalabs.org>\r\nSubject: [Fwd: [INSCRIPTION] =?ISO-8859-1?Q?R=E9c=E9ption_de_votre_?=\r\n =?ISO-8859-1?Q?dossier_d=27inscription_Free_Haut_D=E9bit=5D?=\r\n\r\n'), ')']
resp = [('123 (UID 3 BODYSTRUCTURE (((("text" "plain" ("charset" "iso-8859-1") NIL NIL "quoted-printable" 1266 30 NIL NIL NIL NIL)("text" "html" ("charset" "iso-8859-1") NIL NIL "quoted-printable" 8830 227 NIL NIL NIL NIL) "alternative" ("boundary" "_000_152AC7ECD1F8AB43A9AD95DBDDCA3118082C09GKIMA24cmcicfr_") NIL NIL NIL)("image" "png" ("name" "image005.png") "<image005.png@01CC6CAA.4FADC490>" "image005.png" "base64" 7464 NIL ("inline" ("filename" "image005.png" "size" "5453" "creation-date" "Tue, 06 Sep 2011 13:33:49 GMT" "modification-date" "Tue, 06 Sep 2011 13:33:49 GMT")) NIL NIL)("image" "jpeg" ("name" "image006.jpg") "<image006.jpg@01CC6CAA.4FADC490>" "image006.jpg" "base64" 2492 NIL ("inline" ("filename" "image006.jpg" "size" "1819" "creation-date" "Tue, 06 Sep 2011 13:33:49 GMT" "modification-date" "Tue, 06 Sep 2011 13:33:49 GMT")) NIL NIL) "related" ("boundary" "_006_152AC7ECD1F8AB43A9AD95DBDDCA3118082C09GKIMA24cmcicfr_" "type" "multipart/alternative") NIL NIL NIL)("application" "pdf" ("name" "bilan assurance CIC.PDF") NIL "bilan assurance CIC.PDF" "base64" 459532 NIL ("attachment" ("filename" "bilan assurance CIC.PDF" "size" "335811" "creation-date" "Fri, 16 Sep 2011 12:45:23 GMT" "modification-date" "Fri, 16 Sep 2011 12:45:23 GMT")) NIL NIL)(("text" "plain" ("charset" "utf-8") NIL NIL "quoted-printable" 1389 29 NIL NIL NIL NIL)("text" "html" ("charset" "utf-8") NIL NIL "quoted-printable" 1457 27 NIL NIL NIL NIL) "alternative" ("boundary" "===============0775904800==") ("inline" NIL) NIL NIL) "mixed" ("boundary" "_007_152AC7ECD1F8AB43A9AD95DBDDCA3118082C09GKIMA24cmcicfr_") NIL ("fr-FR") NIL)',), ')']
# resp = [('123 (UID 3 BODYSTRUCTURE (((("text" "plain" ("charset" "iso-8859-1") NIL NIL "quoted-printable" 1266 30 NIL NIL NIL NIL)("text" "html" ("charset" "iso-8859-1") NIL NIL "quoted-printable" 8830 227 NIL NIL NIL NIL) "alternative" ("boundary" "_000_152AC7ECD1F8AB43A9AD95DBDDCA3118082C09GKIMA24cmcicfr_") NIL NIL NIL)("image" "png" ("name" "image005.png") "<image005.png@01CC6CAA.4FADC490>" "image005.png" "base64" 7464 NIL ("inline" ("filename" "image005.png" "size" "5453" "creation-date" "Tue, 06 Sep 2011 13:33:49 GMT" "modification-date" "Tue, 06 Sep 2011 13:33:49 GMT")) NIL NIL)("image" "jpeg" ("name" "image006.jpg") "<image006.jpg@01CC6CAA.4FADC490>" "image006.jpg" "base64" 2492 NIL ("inline" ("filename" "image006.jpg" "size" "1819" "creation-date" "Tue, 06 Sep 2011 13:33:49 GMT" "modification-date" "Tue, 06 Sep 2011 13:33:49 GMT")) NIL NIL) "related" ("boundary" "_006_152AC7ECD1F8AB43A9AD95DBDDCA3118082C09GKIMA24cmcicfr_" "type" "multipart/alternative") NIL NIL NIL)("application" "pdf" ("name" "bilan assurance CIC.PDF") NIL "bilan assurance CIC.PDF" "base64" 459532 NIL ("attachment" ("filename" "bilan assurance CIC.PDF" "size" "335811" "creation-date" "Fri, 16 Sep 2011 12:45:23 GMT" "modification-date" "Fri, 16 Sep 2011 12:45:23 GMT")) NIL NIL)(("text" "plain" ("charset" "utf-8") NIL NIL "quoted-printable" 1389 29 NIL NIL NIL NIL)("text" "html" ("charset" "utf-8") NIL NIL "quoted-printable" 1457 27 NIL NIL NIL NIL) "alternative" ("boundary" "===============0775904800==") ("inline" NIL) NIL NIL) "mixed" ("boundary" "_007_152AC7ECD1F8AB43A9AD95DBDDCA3118082C09GKIMA24cmcicfr_") NIL ("fr-FR") NIL)',), ')']
resp = [('856 (UID 11111 BODYSTRUCTURE ((("text" "plain" ("charset" "UTF-8") NIL NIL "7bit" 0 0 NIL NIL NIL NIL) "mixed" ("boundary" "----=_Part_407172_3159001.1321948277321") NIL NIL NIL)("application" "octet-stream" ("name" "26274308.pdf") NIL NIL "base64" 14906 NIL ("attachment" ("filename" "(26274308.pdf")) NIL NIL) "mixed" ("boundary" "----=_Part_407171_9686991.1321948277321") NIL NIL NIL)',), ')']
# resp = [('856 (UID 11111 BODYSTRUCTURE ((("text" "plain" ("charset" "UTF-8") NIL NIL "7bit" 0 0 NIL NIL NIL NIL) "mixed" ("boundary" "----=_Part_407172_3159001.1321948277321") NIL NIL NIL)("application" "octet-stream" ("name" "26274308.pdf") NIL NIL "base64" 14906 NIL ("attachment" ("filename" "(26274308.pdf")) NIL NIL) "mixed" ("boundary" "----=_Part_407171_9686991.1321948277321") NIL NIL NIL)',), ')']
print parse_fetch_response(resp)
+24 -5
View File
@@ -151,6 +151,7 @@ class IMAPconnector(object):
unseen_pattern = re.compile(r'[^\(]+\(UNSEEN (\d+)\)')
def __init__(self, user=None, password=None):
self.__hdelimiter = None
self.criterions = []
self.address = parameters.get_admin("IMAP_SERVER")
self.port = int(parameters.get_admin("IMAP_PORT"))
@@ -225,6 +226,23 @@ class IMAPconnector(object):
bodystruct[5], int(bodystruct[6]))
return (None, None, 0)
@property
def hdelimiter(self):
"""Return the default hierachy delimiter.
This is a simple way to retrieve the default delimiter (see
http://www.imapwiki.org/ClientImplementation/MailboxList).
:return: a string
"""
if self.__hdelimiter is None:
data = self._cmd("LIST", "", "")
m = self.list_response_pattern.match(data[0])
if m is None:
raise WebmailError(_("Failed to retrieve hierarchy delimiter"))
self.__hdelimiter = m.group('delimiter')
return self.__hdelimiter
def refresh(self, user, password):
"""Check if current connection needs a refresh
@@ -383,7 +401,7 @@ class IMAPconnector(object):
newmboxes += [descr]
if re.search("\%s" % delimiter, name):
parts = name.split(".")
parts = name.split(delimiter)
if not "path" in descr:
descr["path"] = parts[0]
descr["sub"] = []
@@ -397,7 +415,8 @@ class IMAPconnector(object):
@capability('LIST-EXTENDED', '_listmboxes_simple')
def _listmboxes(self, topmailbox, mailboxes, until_mailbox=None):
pattern = ("%s.%%" % topmailbox.encode("imap4-utf-7")) if len(topmailbox) else "%"
pattern = "%s%s%%" % (topmailbox.encode("imap4-utf-7"), self.hdelimiter) \
if len(topmailbox) else "%"
resp = self._cmd("LIST", "", pattern, "RETURN", "(CHILDREN)")
newmboxes = []
for mb in resp:
@@ -459,7 +478,7 @@ class IMAPconnector(object):
"class": "icon-trash"}
]
if until_mailbox:
name, parent = separate_mailbox(until_mailbox)
name, parent = separate_mailbox(until_mailbox, self.hdelimiter)
if parent:
until_mailbox = parent
self._listmboxes(topmailbox, md_mailboxes, until_mailbox)
@@ -543,7 +562,7 @@ class IMAPconnector(object):
def create_folder(self, name, parent=None):
if parent is not None:
name = "%s.%s" % (parent, name)
name = "%s%s%s" % (parent, self.hdelimiter, name)
typ, data = self.m.create(self._encode_mbox_name(name))
if typ == "NO":
raise WebmailError(data[0])
@@ -667,7 +686,7 @@ def separate_mailbox(fullname, sep="."):
If a separator is found in ``fullname``, this function returns the
corresponding name and parent mailbox name.
"""
if fullname.count("."):
if sep in fullname:
parts = fullname.split(sep)
name = parts[-1]
parent = sep.join(parts[0:len(parts) - 1])
+25 -18
View File
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta
import email
import lxml
import chardet
from rfc6266 import build_header, parse_headers
from django.core.files.uploadhandler import FileUploadHandler, SkipFile
from django.utils.translation import ugettext as _, ugettext_lazy
from django.conf import settings
@@ -29,7 +30,7 @@ class WMtable(tables.Table):
"select", cssclass="draggable left", width="2%",
defvalue="%spics/grippy.png" % settings.STATIC_URL,
header="<input type='checkbox' name='toggleselect' id='toggleselect' />"
)
)
flags = tables.ImgColumn("flags", width="4%")
withatts = tables.ImgColumn("withatts", width="2%")
subject = tables.Column(
@@ -216,7 +217,7 @@ class ImapEmail(Email):
self.bs = BodyStructure(msg['BODYSTRUCTURE'])
data = None
mformat = self.dformat if self.bs.contents.has_key(self.dformat) else fallback_fmt
mformat = self.dformat if self.dformat in self.bs.contents else fallback_fmt
if len(self.bs.contents):
bodyc = u''
@@ -278,24 +279,30 @@ class ImapEmail(Email):
return None
def _find_attachments(self):
"""Retrieve attachments from the parsed body structure.
We try to find and decode a file name for each attachment. If
we failed, a generic name will be used (ie. part_1, part_2, ...).
"""
for att in self.bs.attachments:
attname = "part_%s" % att["pnum"]
params = None
key = None
if "params" in att and att["params"] != "NIL":
params = att["params"]
key = "name"
if key is None and \
"disposition" in att and len(att["disposition"]) > 1:
params = att["disposition"][1]
key = "filename"
if key and params:
for pos, value in enumerate(params):
if value == key:
attname = u2u_decode.u2u_decode(params[pos + 1]).strip("\r\t\n")
break
attname = u2u_decode.u2u_decode(att["params"][1]) \
.strip("\r\t\n")
elif "disposition" in att and len(att["disposition"]) > 1:
for pos, value in enumerate(att["disposition"][1]):
if not value.startswith("filename"):
continue
header = "%s; %s=%s" \
% (att['disposition'][0],
value,
att["disposition"][1][pos + 1].strip("\r\t\n"))
attname = parse_headers(header).filename_unsafe
if attname is None:
attname = u2u_decode.u2u_decode(
att["disposition"][1][pos + 1]
).strip("\r\t\n")
break
self.attachments[att["pnum"]] = attname
def _fetch_inlines(self):
@@ -626,7 +633,7 @@ def create_mail_attachment(attdef):
res.set_payload(fp.read())
fp.close()
Encoders.encode_base64(res)
res.add_header("Content-Disposition", "attachment; filename='%s'" % attdef["fname"])
res['Content-Disposition'] = build_header(attdef['fname'].decode('utf-8'))
return res
@@ -16,6 +16,10 @@ body, html {
overflow: auto;
}
#mboxes_container .nav-list .nav-list {
padding-right: 0;
}
#menubar {
position: fixed;
left: 215px;
@@ -15,7 +15,8 @@ Webmail.prototype = {
submboxes_url: "",
delattachment_url: "",
ro_mboxes: ["INBOX"],
trash: ""
trash: "",
hdelimiter: '.'
},
initialize: function(options) {
@@ -82,7 +83,7 @@ Webmail.prototype = {
$(document).on("click", "a[name=forward]", $.proxy(this.reply_loader, this));
$(document).on("click", "a[name=delete]", $.proxy(this.delete_message, this));
$(document).on("click", "a[name=activate_links]", $.proxy(function(e) { this.display_mode(e, "1"); }, this));
$(document).on("click", "a[name=disable_links]", $.proxy(function(e) { this.disable_mode(e, "0"); }, this));
$(document).on("click", "a[name=disable_links]", $.proxy(function(e) { this.display_mode(e, "0"); }, this));
$(document).on("click", "a[name=sendmail]", $.proxy(this.sendmail, this));
@@ -217,7 +218,7 @@ Webmail.prototype = {
}
var $link = $('a[href="' + mailbox + '"]');
var parts = mailbox.split('.');
var parts = mailbox.split(this.options.hdelimiter);
var dname = " " + parts[parts.length - 1];
var $i = $link.children("i");
@@ -285,7 +286,7 @@ Webmail.prototype = {
'class': "block",
href: mailbox
});
var parts = mailbox.split(".");
var parts = mailbox.split(this.options.hdelimiter);
var linkcontent = "<i class='icon-folder-close'></i> ";
var displayname = linkcontent + parts[parts.length - 1];
@@ -401,18 +402,18 @@ Webmail.prototype = {
* to access this mailbox are also loaded)
*/
load_and_select_mailbox: function(mailbox) {
if (mailbox.indexOf(".") == -1) {
if (mailbox.indexOf(this.options.hdelimiter) == -1) {
this.select_mailbox(mailbox);
return;
}
this.reset_mb_selection();
var parts = mailbox.split(".");
var parts = mailbox.split(this.options.hdelimiter);
var curmb = parts[0], lastmb = "";
for (var i = 1; i < parts.length; i++) {
lastmb = curmb;
curmb += "." + parts[i];
curmb += this.options.hdelimiter + parts[i];
var $link = $('a[href="' + curmb + '"]');
var $container = $('li[name="' + lastmb + '"]');
@@ -535,7 +536,7 @@ Webmail.prototype = {
return;
}
$parent = $ul;
mailbox = $parent.attr("name") + "." + mailbox;
mailbox = $parent.attr("name") + this.options.hdelimiter + mailbox;
} else {
$parent = $("#mboxes_container").children("ul");
}
@@ -548,8 +549,8 @@ Webmail.prototype = {
* If needed, the mailbox will be moved to its new location.
*/
rename_mailbox: function(oldname, newname, oldparent, newparent) {
var oldpattern = (oldparent) ? oldparent + "." + oldname : oldname;
var newpattern = (newparent) ? newparent + "." + newparent : newname;
var oldpattern = (oldparent) ? oldparent + this.options.hdelimiter + oldname : oldname;
var newpattern = (newparent) ? newparent + this.options.hdelimiter + newname : newname;
var $link = $("#folders").find('a[href="' + oldpattern + '"]');
if (oldname != newname) {
@@ -559,14 +560,13 @@ Webmail.prototype = {
$link.parent("li").attr("name", newpattern);
$link.prepend($i);
$link.attr("href", newpattern);
this.navobject.setparam("mbox", newname).update(false, true);
this.navobject.setparam("mbox", newpattern).update(false, true);
}
if (oldparent != newparent) {
var newlocation = (newparent) ? newparent + "." + newname : newname;
this.remove_mbox_from_tree($link.parent("li"));
this.add_mailbox_to_tree(newparent, newname);
if (this.navobject.getparam("action") == "listmailbox") {
this.navobject.setparam("mbox", newlocation).update();
this.navobject.setparam("mbox", newpattern).update();
} else {
this.select_mailbox("INBOX");
}
@@ -709,14 +709,14 @@ Webmail.prototype = {
* is clicked)
*/
compose_loader: function(e) {
var $link = $(e.target);
var $link = get_target(e, 'a');
e.preventDefault();
this.navobject.reset().setparam("action", $link.attr("href")).update();
},
reply_loader: function(e) {
var $link = $(e.target);
var $link = get_target(e, "a");
e.preventDefault();
this.navobject.reset().updateparams($link.attr("href")).update();
},
@@ -4,7 +4,7 @@
<div id="folders2" class="well">
<ul class="nav nav-list">
<li class="nav-header">{% trans "Parent mailbox" %}</li>
{% print_mailboxes mboxes selected withunseen selectonly %}
{% print_mailboxes mboxes selected withunseen selectonly hdelimiter %}
</ul>
</div>
{% endblock %}
@@ -20,7 +20,8 @@ $(document).ready(function() {
deflocation: "{{ deflocation }}",
defcallback: "{{ defcallback }}",
ro_mboxes: {% tolist ro_mboxes %},
trash: "{{ trash }}"
trash: "{{ trash }}",
hdelimiter: '{{ hdelimiter }}'
});
});
</script>
@@ -124,7 +124,7 @@ def listmailbox_menu(selection, folder, user):
@register.simple_tag
def print_mailboxes(tree, selected=None, withunseen=False, selectonly=False):
def print_mailboxes(tree, selected=None, withunseen=False, selectonly=False, hdelimiter='.'):
"""Display a tree of mailboxes and sub-mailboxes
:param tree: the mailboxes to display
@@ -134,7 +134,7 @@ def print_mailboxes(tree, selected=None, withunseen=False, selectonly=False):
for mbox in tree:
cssclass = ""
name = mbox["path"] if "sub" in mbox else mbox["name"]
label = separate_mailbox(mbox["name"])[0]
label = separate_mailbox(mbox["name"], hdelimiter)[0]
if selected == name:
cssclass = "active"
result += "<li name='%s' class='droppable %s'>\n" % (name, cssclass)
@@ -164,7 +164,7 @@ def print_mailboxes(tree, selected=None, withunseen=False, selectonly=False):
if "sub" in mbox and len(mbox["sub"]):
result += "<ul name='%s' class='nav nav-list %s'>" % (mbox["path"], ul_state) \
+ print_mailboxes(mbox["sub"], selected, withunseen, selectonly) + "</ul>\n"
+ print_mailboxes(mbox["sub"], selected, withunseen, selectonly, hdelimiter) + "</ul>\n"
result += "</li>\n"
return result
+17 -7
View File
@@ -1,5 +1,6 @@
# coding: utf-8
import os
from rfc6266 import build_header
from django.conf import settings
from django.http import HttpResponse
from django.shortcuts import render
@@ -8,6 +9,7 @@ from django.utils.translation import ugettext as _, ungettext
from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.gzip import gzip_page
from modoboa.lib import parameters
from modoboa.lib.webutils import (
_render_to_string, ajax_response, ajax_simple_response
@@ -27,6 +29,7 @@ from templatetags import webextras
@login_required
@needs_mailbox()
@gzip_page
def getattachment(request):
"""Fetch a message attachment
@@ -51,11 +54,15 @@ def getattachment(request):
# FIXME : ugly hack, see fetch_parser.py for more explanation
# :p
if type(disp[1][0]) != dict:
cd = '%s; %s="%s"' % (disp[0], disp[1][0], disp[1][1])
atype, fieldname, filename = disp[0], disp[1][0], disp[1][1]
else:
cd = '%s; %s="%s"' % (disp[0], disp[1][0]['struct'][0], disp[1][0]['struct'][1])
atype, fieldname, filename = disp[0], disp[1][0]['struct'][0], disp[1][0]['struct'][1]
if fieldname.endswith('*'):
cd = '%s; %s=%s' % (atype, fieldname, filename)
else:
cd = '%s; %s="%s"' % (atype, fieldname, filename)
else:
cd = 'attachment; filename="%s"' % request.GET["fname"]
cd = build_header(request.GET["fname"])
resp["Content-Disposition"] = cd
resp["Content-Length"] = partdef["size"]
return resp
@@ -144,7 +151,8 @@ def newfolder(request, tplname="webmail/folder.html"):
"action_classes": "submit",
"withunseen": False,
"selectonly": True,
"mboxes": mbc.getmboxes(request.user)}
"mboxes": mbc.getmboxes(request.user),
"hdelimiter": mbc.hdelimiter}
if request.method == "POST":
form = FolderForm(request.POST)
@@ -176,14 +184,15 @@ def editfolder(request, tplname="webmail/folder.html"):
"action_label": _("Update"),
"action_classes": "submit",
"withunseen": False,
"selectonly": True}
"selectonly": True,
"hdelimiter": mbc.hdelimiter}
if request.method == "POST":
form = FolderForm(request.POST)
if form.is_valid():
pf = request.POST.get("parent_folder", None)
ctx["selected"] = pf
oldname, oldparent = separate_mailbox(request.POST["oldname"])
oldname, oldparent = separate_mailbox(request.POST["oldname"], sep=mbc.hdelimiter)
res = dict(status="ok", respmsg=_("Mailbox updated"))
if form.cleaned_data["name"] != oldname \
or (pf != oldparent):
@@ -205,7 +214,7 @@ def editfolder(request, tplname="webmail/folder.html"):
name = request.GET.get("name", None)
if name is None:
raise WebmailError(_("Invalid request"))
shortname, parent = separate_mailbox(name)
shortname, parent = separate_mailbox(name, sep=mbc.hdelimiter)
ctx["mboxes"] = mbc.getmboxes(request.user, until_mailbox=parent)
ctx["form"] = FolderForm()
ctx["form"].fields["oldname"].initial = name
@@ -537,6 +546,7 @@ def index(request):
if not request.is_ajax():
request.session["lastaction"] = None
imapc = get_imapconnector(request)
response["hdelimiter"] = imapc.hdelimiter
response["mboxes"] = render_mboxes_list(request, imapc)
imapc.getquota(curmbox)
response["refreshrate"] = \
+8 -9
View File
@@ -23,7 +23,7 @@ class EmailAddress(object):
return self.fulladdress
class Email(object):
def __init__(self, msg, mformat="plain", dformat="plain", links="0"):
def __init__(self, msg, mformat="plain", dformat="plain", links=0):
self.attached_map = {}
self.contents = {"html" : "", "plain" : ""}
self.headers = []
@@ -78,16 +78,16 @@ class Email(object):
sometimes, messages embark inline images, which means they
must be displayed and not attached.
"""
if self.dformat == "html" and self.links != "0" \
and msg.has_key("Content-Disposition"):
if self.dformat == "html" and self.links \
and "Content-Disposition" in msg:
if msg["Content-Disposition"].startswith("inline"):
cid = None
if msg.has_key("Content-ID"):
if "Content-ID" in msg:
m = re.match("<(.+)>", msg["Content-ID"])
cid = m is not None and m.group(1) or msg["Content-ID"]
fname = msg.get_filename()
if fname is None:
if msg.has_key("Content-Location"):
if "Content-Location" in msg:
fname = msg["Content-Location"]
elif cid is not None:
fname = cid
@@ -167,9 +167,9 @@ class Email(object):
def viewmail_html(self, content, **kwargs):
if content is None or content == "":
return ""
links = kwargs.has_key("links") and kwargs["links"] or "0"
html = lxml.html.fromstring(content)
if links == "0":
links = kwargs.get("links", 0)
html = lxml.html.fromstring(content)
if not links:
html.rewrite_links(lambda x: None)
else:
html.rewrite_links(self.map_cid)
@@ -179,7 +179,6 @@ class Email(object):
else:
body = lxml.html.tostring(body)
body = re.sub("<(/?)body", lambda m: "<%sdiv" % m.group(1), body)
body = Template(body).render(Context({}))
return body
def split_mailbox(mailbox):
+31 -26
View File
@@ -1,45 +1,50 @@
#!/usr/bin/env python
# u2u_decode.py
# unstructured rfc2047 header to unicode
#
# coding: utf-8
"""
u2u_decode.py
unstructured rfc2047 header to unicode
"""
import re
from email.header import decode_header, make_header
# check spaces between encoded_words (and strip them)
sre = re.compile(r'\?=[ \t]+=\?')
# re pat for MIME encoded_word (without trailing spaces)
mre = re.compile(r'=\?[^?]*?\?[bq]\?[^? \t]*?\?=', re.I)
mre = re.compile(r'=\?[^?]*?\?[bq]\?[^?\t]*?\?=', re.I)
def clean_spaces(m):
"""Replace unencoded spaces in string
:param str m: a match object
:return: the cleaned string
"""
return m.group(0).replace(" ", "=20")
tre = re.compile('=\?[^?]*\?=')
def decode_mime(m):
# substitute matching encoded_word with unicode equiv.
h = decode_header(m.group(0))
"""substitute matching encoded_word with unicode equiv.
"""
h = decode_header(clean_spaces(m))
u = unicode(make_header(h))
return u
def clean_spaces(m):
return m.group(0).replace(" ", "=20")
def u2u_decode(s):
# utility function for (final) decoding of mime header
# note: resulting string is in one line (no \n within)
# note2: spaces between enc_words are stripped (see RFC2047)
ur"""utility function for (final) decoding of mime header
note: resulting string is in one line (no \n within)
note2: spaces between enc_words are stripped (see RFC2047)
>>> u2u_decode('=?ISO-8859-15?Q?=20Profitez de tous les services en ligne sur impots.gouv.fr?=')
u' Profitez de tous les services en ligne sur impots.gouv.fr'
>>> u2u_decode('=?ISO-8859-1?Q?Accus=E9?= de =?ISO-8859-1?Q?r=E9ception?= de votre annonce')
u'Accus\xe9 de r\xe9ception de votre annonce'
>>> u2u_decode('Sm=?ISO-8859-1?B?9g==?=rg=?ISO-8859-1?B?5Q==?=sbord')
u'Sm\xf6rg\xe5sbord'
"""
s = ''.join(s.splitlines())
s = sre.sub('?==?', s)
s = tre.sub(clean_spaces, s)
u = mre.sub(decode_mime, s)
return u
if __name__ == '__main__':
import sys
if len(sys.argv) > 1:
s = sys.argv[1]
else:
s = """[Apache-Users 7784] ServerAlias =?ISO-2022-JP?B?GyRAGyRCJEsbKEI=?=:(
=?ISO-2022-JP?B?GyRCJTMlbSVzGyhC?=)=?ISO-2022-JP?B?GyRCJHI7SBsoQg==?=
=?ISO-2022-JP?B?GyRAGyRCTVEkOSRrJEg1c0YwJCwkKiQrJDckJBsoQg==?="""
s = 'Sm=?ISO-8859-1?B?9g==?=rg=?ISO-8859-1?B?5Q==?=sbord'
u = u2u_decode(s)
print u
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -36,9 +36,11 @@
id: "confirmbox",
'class': "modal"
});
var question = (typeof this.options.question == 'function')
? this.options.question.apply(this) : this.options.question;
box.append($('<div class="modal-body">'
+ '<h4>' + this.options.question + '</h4>'));
+ '<h4>' + question + '</h4>'));
var $container = $("<div />", {"class" : "container-fluid"});
box.append($container);
if (this.options.warning) {
+3 -7
View File
@@ -134,16 +134,12 @@ History.prototype = {
}
var elems = str.split('&');
for (var i = 0; i < elems.length; i++) {
this.setparamfromstring(elems[i]);
var pdef = elems[i].split('=');
this.setparam(pdef[0], pdef[1]);
}
return this;
},
setparamfromstring: function(str) {
var def = str.split('=');
this.params[def[0]] = def[1];
},
setparam: function(name, value, encode) {
if (encode == undefined || encode) {
this.params[name] = encodeURIComponent(value);
@@ -183,7 +179,7 @@ History.prototype = {
},
getbaseurl: function() {
return this.base.substr(0, this.base.length - 1);
return decodeURIComponent(this.base.substr(0, this.base.length - 1));
},
delparam: function(name) {
+2
View File
@@ -6,3 +6,5 @@ pycrypto
sievelib
chardet
argparse
rfc6266
factory-boy