diff --git a/README.rst b/README.rst index b576d1a0f..9af67fb38 100644 --- a/README.rst +++ b/README.rst @@ -59,7 +59,3 @@ The following external libraries are provided with Modoboa: :target: https://crate.io/packages/modoboa/ .. |travis| image:: https://travis-ci.org/tonioo/modoboa.png?branch=master :target: https://travis-ci.org/tonioo/modoboa - -.. image:: https://d2weczhvl823v0.cloudfront.net/tonioo/modoboa/trend.png - :alt: Bitdeli badge - :target: https://bitdeli.com/free diff --git a/dev-requirements.txt b/dev-requirements.txt index 2e2b62f67..06d73f33c 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1 +1,2 @@ django-bower +django-debug-toolbar diff --git a/doc/conf.py b/doc/conf.py index 1bef7c83d..222d6759b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -50,7 +50,7 @@ copyright = u'2014, Antoine Nguyen' # The short X.Y version. version = '1.1' # The full version, including alpha/beta/rc tags. -release = '1.1.6' +release = '1.1.7' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/doc/extending/events.rst b/doc/extending/events.rst index 41b742ff7..3b812db0b 100644 --- a/doc/extending/events.rst +++ b/doc/extending/events.rst @@ -715,10 +715,11 @@ Raised when an existing mailbox is modified. *Callback prototype*:: - def callback(newmailbox, oldmailbox): pass + def callback(mailbox): pass -* ``newmailbox`` is a ``Mailbox`` instance containing the new values -* ``oldmailbox`` is a ``Mailbox`` instance containing the old values +* ``mailbox`` is the ``Mailbox`` modified instance. It contains a + ``old_full_address`` extra field to check if the address was + modified. PasswordChange ============== diff --git a/doc/getting_started/plugins.rst b/doc/getting_started/plugins.rst new file mode 100644 index 000000000..072a9c3dc --- /dev/null +++ b/doc/getting_started/plugins.rst @@ -0,0 +1,524 @@ +############# +Using plugins +############# + +************************** +Enable or disable a plugin +************************** + +Modoboa provides an online panel to control plugins activation. You +will find it at *Modoboa > Extensions*. + +To activate a plugin, check the corresponding box and click on the +*Apply* button. + +To deactivate a plugin, uncheck the corresponding box and click on the +*Apply* button. + +**************** +Per-admin limits +**************** + +This plugin offers a way to define limits about how many objects +(aliases, mailboxes) a domain administrator can create. + +It also brings a new administrative role: ``Reseller``. A reseller is a domain +administrator that can also manipulate domains and assign permissions +to domain administrators. + +If you don't want to limit a particular object type, just set the +associated value to -1. + +Default limits applied to new administrators can be changed through +the *Modoboa > Parameters > Limits* page. + +***************************** +Postfix relay domains support +***************************** + +This plugin adds the support for relay domains using postfix. You can +use it when the MTA managed by Modoboa is not the final destination of +one or several domains. + +If activated, two new objects will be available from the *Domains* +listing page: *relay domain* and *relay domain alias*. + +The extension is compatible with the *amavis* and *limits* +ones. Resellers will be able to create both new objects. + +Replace by the name of the database you use.To tell Postfix this feature exists, you must generate two new map +files and then update your configuration. + +To generate the map files, run the following command:: + + $ modoboa-admin.py postfix_maps --categories relaydomains --dbtype + +Replace values between ``<>`` by yours. + +Edit the :file:`/etc/postfix/main.cf` file and copy the following +lines inside:: + + relay_domains = :/etc/postfix/sql-relaydomains.cf + transport_maps = + :/etc/postfix/sql-relaydomains-transport.cf + :/etc/postfix/sql-relaydomain-aliases-transport.cf + + smtpd_recipient_restrictions = + permit_mynetworks + reject_unauth_destination + check_recipient_access + :/etc/postfix/sql-relay-recipient-verification.cf + +Replace ```` by the name of the database you use. + +Reload postfix. + +.. _amavis_frontend: + +******************** +Amavisd-new frontend +******************** + +This plugin provides a simple management frontend for `amavisd-new +`_. The supported features are: + +* SQL quarantine management : available to administrators or users, + possibility to delete or release messages +* Per domain customization (using policies): specify how amavisd-new + will handle traffic + +.. note:: + + 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). + +.. note:: + + 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**. + +Quick Amavis setup +================== + +By default, amavis doesn't use a database. To configure this +behaviour, you first need to create a dedicated database. This step is a +bit manual since no *ready-to-use* SQL schema is provided by +amavis. The information is located inside README files, one for `MySQL +`_ and one for `PostgreSQL +`_. + +Then, you must tell amavis to use this database for lookups and +quarantined messages storing. Here is a working configuration sample:: + + @lookup_sql_dsn = + (['DBI::database=;host=;port=', '', ']']); + + @storage_sql_dsn = + (['DBI::database=;host=;port=', '', ']']); + + # PostgreSQL users NEED this parameter! + # MySQL users only need this parameter is email addresses are stored + # using the VARBINARY type. + $sql_allow_8bit_address = 1; + + $virus_quarantine_method = 'sql:'; + $spam_quarantine_method = 'sql:'; + $banned_files_quarantine_method = 'sql:'; + $bad_header_quarantine_method = 'sql:'; + + $virus_quarantine_to = 'virus-quarantine'; + $banned_quarantine_to = 'banned-quarantine'; + $bad_header_quarantine_to = 'bad-header-quarantine'; + $spam_quarantine_to = 'spam-quarantine'; + +Replace values between ``<>`` by yours. To know how to configure +amavis to allow quarantined messages release, read this :ref:`section +`. + +.. note:: + + Amavis configuration allows for separate lookup and storage + databases but Modoboa doesn't support it yet. + +Connect Modoboa and Amavis +========================== + +You must tell to Modoboa where it can find the amavis +database. Inside :file:`settings.py`, add a new connection to the +``DATABASES`` variable like this:: + + DATABASES = { + # Stuff before + # + "amavis": { + "ENGINE" : "", + "HOST" : "", + "NAME" : "", + "USER" : "", + "PASSWORD" : "" + } + } + +Replace values between ``<>`` with yours. + +Cleanup +------- + +Storing quarantined messages to a database can quickly become a +perfomance killer. Modoboa provides a simple script to periodically +purge the quarantine database. To use it, add the following line +inside root's crontab:: + + 0 0 * * * /manage.py qcleanup + # + # Or like this if you use a virtual environment: + # 0 0 * * * /manage.py qcleanup + +Replace ``modoboa_site`` with the path of your Modoboa instance. + +By default, messages older than 14 days are automatically purged. You +can modify this value by changing the ``MAX_MESSAGES_AGE`` parameter +in the online panel. + +.. _amavis_release: + +Release messages +================ + +To release messages, first take a look at `this page +`_. It +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:: + + $interface_policy{'SOCK'} = 'AM.PDP-SOCK'; + $interface_policy{'9998'} = 'AM.PDP-INET'; + + $policy_bank{'AM.PDP-SOCK'} = { + protocol => 'AM.PDP', + auth_required_release => 0, + }; + $policy_bank{'AM.PDP-INET'} = { + protocol => 'AM.PDP', + inet_acl => [qw( 127.0.0.1 [::1] )], + }; + +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 +the *release server* by modifying the following parameters in the +online panel: + ++--------------------+--------------------+------------------------+ +|Name |Description |Default value | ++====================+====================+========================+ +|Amavis connection |Mode used to access |unix | +|mode |the PDP server | | ++--------------------+--------------------+------------------------+ +|PDP server address |PDP server address |localhost | +| |(if inet mode) | | ++--------------------+--------------------+------------------------+ +|PDP server port |PDP server port (if | | +| |inet mode) 9998 | | ++--------------------+--------------------+------------------------+ +|PDP server socket |Path to the PDP |/var/amavis/amavisd.sock| +| |server socket (if | | +| |unix mode) | | ++--------------------+--------------------+------------------------+ + +Deferred release +---------------- + +By default, simple users are not allowed to release messages +themselves. They are only allowed to send release requests to +administrators. + +As administrators are not always available or logged into Modoboa, a +notification tool is available. It sends reminder e-mails to every +administrators or domain administrators. To use it, add the following +example line to root's crontab:: + + 0 12 * * * /manage.py amnotify --baseurl='' + # + # Or like this if you use a virtual environment: + # 0 12 * * * /manage.py amnotify --baseurl='' + +You are free to change the frequency. + +.. note:: + + If you want to let users release their messages alone (not + recommended), go to the admin panel. + +The following parameters are available to let you customize this +feature: + ++--------------------+--------------------+------------------------+ +|Name |Description |Default value | ++====================+====================+========================+ +|Check requests |Interval between two|30 | +|interval |release requests | | +| |checks | | ++--------------------+--------------------+------------------------+ +|Allow direct release|Allow users to |no | +| |directly release | | +| |their messages | | ++--------------------+--------------------+------------------------+ +|Notifications sender|The e-mail address |notification@modoboa.org| +| |used to send | | +| |notitications | | ++--------------------+--------------------+------------------------+ + +.. _selfservice: + +Self-service mode +================= + +The *self-service* mode lets users act on quarantined messages without +beeing authenticated. They can: + +* View messages +* Remove messages +* Release messages (or send release requests) + +To access a specific message, they only need the following information: + +* Message's unique identifier +* Message's secret identifier + +This information is controlled by *amavis*, which is in charge of +notifying users when new messages are put into quarantine. Each +notification (one per message) must embark a direct link containing +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 +sends. The most important is to embark a direct link. Take a look at +the `README.customize `_ file to +learn what you're allowed to do. + +Here is a link example:: + + http:///quarantine/%i/?rcpt=%R&secret_id=[:secret_id] + +.. _stats: + +******************** +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 `_) and then +generates graphics in PNG format. + +To use it, go to the online parameters panel and adapt the following +ones to your environment: + ++--------------------+--------------------+--------------------------+ +|Name |Description |Default value | ++====================+====================+==========================+ +|Path to the log file|Path to log file |/var/log/mail.log | +| |used to collect | | +| |statistics | | ++--------------------+--------------------+--------------------------+ +|Directory to store |Path to directory |/tmp/modoboa | +|RRD files |where RRD files are | | +| |stored | | ++--------------------+--------------------+--------------------------+ +|Directory to store |Path to directory |/media/stats| +|PNG files |where PNG files are | | +| |stored | | ++--------------------+--------------------+--------------------------+ + +Make sure the directory that will contain RRD files exists. If not, +create it before going further. For example (according to the previous +parameters):: + + $ mkdir /tmp/modoboa + +To finish, you need to collect information periodically in order to +feed the RRD files. Add the following line into root's crontab:: + + */5 * * * * /manage.py logparser &> /dev/null + # + # Or like this if you use a virtual environment: + # 0/5 * * * * /manage.py logparser &> /dev/null + +Replace ```` with the path of your Modoboa instance. + +Graphics will be automatically created after each parsing. + +.. _postfix_ar: + +*************************** +Postifx auto-reply messages +*************************** + +This plugin let users define an auto-reply message (*vacation*). It is +based on Postfix capabilities. + +The user that executes the autoreply script needs to access +: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 +configuration files as follows: + +``/etc/postfix/main.cf``:: + + transport_maps = :/etc/postfix/sql-autoreplies-transport.cf + virtual_alias_maps = :/etc/postfix/sql-aliases.cf + :/etc/postfix/sql-domain-aliases-mailboxes.cf, + :/etc/postfix/sql-autoreplies.cf, + :/etc/postfix/sql-catchall-aliases.cf + +.. note:: + + The order used to define alias maps is important, please respect it + +``/etc/postfix/master.cf``:: + + autoreply unix - n n - - pipe + flags= user=vmail: argv=python /manage.py autoreply $sender $mailbox + +Replace ```` by the name of the database you +use. ```` is the path of your Modoboa instance. + +Then, create the requested map files:: + + $ modoboa-admin.py postfix_maps mapfiles --categories autoreply + +`mapfiles` is the directory where the files will be stored. Answer the +few questions and you're done. + +.. note:: + + Auto-reply messages are just sent one time per sender for a + pre-defined time period. By default, this period is equal to 1 day + (86400s), you can adjust this value by modifying the **Automatic + reply timeout** parameter available in the online panel. + +************* +Sieve filters +************* + +This plugin let users create server-side message filters, using the +`sievelib module `_ (which +provides Sieve and ManageSieve clients). + +Two working modes are available: + +* A raw mode: you create filters using the Sieve language directly + (advanced users) +* An assisted mode: you create filters using an intuitive form + +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 +:ref:`dovecot` to know how to enable those features). + +.. note:: + The sieve filters plugin requires that the :ref:`webmail` plugin is + activated and configured. + +Go the online panel and modify the following parameters in order to +communicate with the *ManageSieve* server: + ++--------------------+--------------------+--------------------+ +|Name |Description |Default value | ++====================+====================+====================+ +|Server address |Address of your |127.0.0.1 | +| |MANAGESIEVE server | | ++--------------------+--------------------+--------------------+ +|Server port |Listening port of |4190 | +| |your MANAGESIEVE | | +| |server | | ++--------------------+--------------------+--------------------+ +|Connect using |Use the STARTTLS |no | +|STARTTLS |extension | | ++--------------------+--------------------+--------------------+ +|Authentication |Prefered |auto | +|mechanism |authentication | | +| |mechanism | | ++--------------------+--------------------+--------------------+ + +.. _webmail: + +******* +Webmail +******* + +Modoboa provides a simple webmail: + +* Browse, read and compose messages, attachments are supported +* HTML messages are supported +* `CKeditor `_ integration +* Manipulate mailboxes (create, move, remove) +* Quota display + +To use it, go to the online panel and modify the following parameters +to communicate with your *IMAP* server (under *IMAP settings*): + ++--------------------+--------------------+--------------------+ +|Name |Description |Default value | ++====================+====================+====================+ +|Server address |Address of your IMAP|127.0.0.1 | +| |server | | ++--------------------+--------------------+--------------------+ +|Use a secured |Use a secured |no | +|connection |connection to access| | +| |IMAP server | | ++--------------------+--------------------+--------------------+ +|Server port |Listening port of |143 | +| |your IMAP server | | ++--------------------+--------------------+--------------------+ + +Do the same to communicate with your SMTP server (under *SMTP settings*): + ++--------------------+--------------------+--------------------+ +|Name |Description |Default value | ++====================+====================+====================+ +|Server address |Address of your SMTP|127.0.0.1 | +| |server | | ++--------------------+--------------------+--------------------+ +|Secured connection |Use a secured |None | +|mode |connection to access| | +| |SMTP server | | ++--------------------+--------------------+--------------------+ +|Server port |Listening port of |25 | +| |your SMTP server | | ++--------------------+--------------------+--------------------+ +|Authentication |Server needs |no | +|required |authentication | | ++--------------------+--------------------+--------------------+ + +.. note:: + + The size of each attachment sent with messages is limited. You can + change the default value by modifying the **Maximum attachment + size** parameter. + +Using CKeditor +============== + +Modoboa supports CKeditor to compose HTML messages. To use it, first +download it from `the official website `_, then +extract the tarball:: + + $ cd + $ tar xzf /path/to/ckeditor/tarball.tag.gz -C sitestatic/js/ + +And you're done! + +Now, each user has the possibility to choose between CKeditor and the +raw text editor to compose their messages. (see *User > Settings > +Preferences > Webmail*) diff --git a/doc/getting_started/upgrade.rst b/doc/getting_started/upgrade.rst index 0ebac6897..25b5236ec 100644 --- a/doc/getting_started/upgrade.rst +++ b/doc/getting_started/upgrade.rst @@ -51,6 +51,27 @@ Then, follow the common procedure:: Eventually, restart the web server process according to the environment you did choose. See :ref:`webservers` for more details. +1.2.0: radicale extension, bootstrap3 and more +============================================== + +A new notification service let administrator know about new Modoboa +versions. To activate it, you need to: + +#. Add `'modoboa.core.context_processors.top_notifications'`` to the + ``TEMPLATE_CONTEXT_PROCESSORS`` variable + +#. Define the ``MODOBOA_API_URL``:: + + MODOBOA_API_URL = 'http://api.modoboa.org/1/' + +1.1.7: manual learning for SpamAssassin +======================================= + +A new feature allows administrators and users to manually train +SpamAssassin in order to customize its behaviour. + +Check :ref:`sa_manual_learning` to know more about this feature. + 1.1.6: Few bugfixes =================== diff --git a/doc/plugins/amavis.rst b/doc/plugins/amavis.rst index 8f6f37bca..fb5b043c9 100644 --- a/doc/plugins/amavis.rst +++ b/doc/plugins/amavis.rst @@ -203,7 +203,7 @@ feature: Self-service mode ================= -The *self-service* mode let users act on quarantined messages without +The *self-service* mode lets users act on quarantined messages without beeing authenticated. They can: * View messages @@ -232,3 +232,76 @@ Here is a link example:: http:///quarantine/%i/?rcpt=%R&secret_id=[:secret_id] +.. _sa_manual_learning: + +Manual SpamAssassin learning +============================ + +It is possible to manually train `SpamAssassin +`_ using the quarantine's content. By +train, we mean: + +* Mark message(s) as spam (false negative(s)) + +* Mark message(s) as non-spam (false positive(s)) + +This feature is available to all users (from super administrators to +simple users) but not enabled by default. + +SpamAssassin configuration +-------------------------- + +For better performance and to enable the per-user level, SpamAssassin +must store bayes information into a SQL database. + +Create a new database and a new user/password (using your favorite +database server) and edit the default configuration file +(:file:`/etc/spamassassin/local.cf`) to add the following lines +inside: + +.. sourcecode:: perl + + bayes_store_module Mail::SpamAssassin::BayesStore:: + bayes_sql_dsn + bayes_sql_username + bayes_sql_password + +Replace values between ``<>`` by yours. Possible values for ``Driver`` +are ``PgSQL`` or ``MySQL`` (non exhaustive list). The syntax for +``DSN`` depends on the driver you choose. Please consult the official +documentation. + +Enable the feature through Modoboa +---------------------------------- + +Manual learning is disabled by default. You can activate it through +the administration panel (*Modoboa > Paremeters > Amavis*). There two +learning levels: + +#. Global: available to administrators only. A single (global) bayes + database is shared between everyone. + +#. Per domain: available to administrators and domain + administrators. Each domain can have a dedicated database. + +#. Per user: each user can create its own database to customize the + way SpamAssassin will detect spam. + +The domain and user levels are not activated by default, dedicated +parameters are available through the panel. + +.. note:: + + Domain and user databases are only created the first time someone + calls the learning feature through the quarantine. + +.. warning:: + + A bayes database needs to reach pre-defined thresholds before it + can be used by SpamAssassin. The default values are **200** spams + and **200** hams. + +You will find other paramaters related to this feature. You won't need +to change them most of the time, unless SpamAssassin is hosted on a +different machine than Modoboa. (in this case, ``spamc`` will be used +instead of ``sa-learn``). diff --git a/modoboa/extensions/admin/forms/account.py b/modoboa/extensions/admin/forms/account.py index 6e6045652..b2b55eb33 100644 --- a/modoboa/extensions/admin/forms/account.py +++ b/modoboa/extensions/admin/forms/account.py @@ -130,6 +130,9 @@ class AccountFormGeneral(forms.ModelForm): class AccountFormMail(forms.Form, DynamicForm): + + """Form to handle mail part.""" + email = forms.EmailField(label=ugettext_lazy("E-mail"), required=False) quota = forms.IntegerField( label=ugettext_lazy("Quota"), @@ -253,7 +256,43 @@ class AccountFormMail(forms.Form, DynamicForm): self.mb.save() events.raiseEvent('MailboxModified', self.mb) + def _update_aliases(self, user): + """Update mailbox aliases.""" + aliases = [] + for name, value in self.cleaned_data.iteritems(): + if not name.startswith("aliases"): + continue + if value == "": + continue + aliases.append(value) + + for alias in self.mb.alias_set.all(): + if not alias.full_address in aliases: + if len(alias.get_recipients()) >= 2: + continue + alias.delete() + else: + aliases.remove(alias.full_address) + if not aliases: + return + events.raiseEvent( + "CanCreate", user, "mailbox_aliases", len(aliases) + ) + for alias in aliases: + local_part, domname = split_mailbox(alias) + try: + self.mb.alias_set.get(address=local_part, domain__name=domname) + except Alias.DoesNotExist: + pass + else: + continue + al = Alias(address=local_part, enabled=account.is_active) + al.domain = Domain.objects.get(name=domname) + al.save(int_rcpts=[self.mb]) + al.post_create(user) + def save(self, user, account): + """Save or update account mailbox.""" if self.cleaned_data["email"] == "": return None @@ -271,36 +310,7 @@ class AccountFormMail(forms.Form, DynamicForm): account.email = self.cleaned_data["email"] account.save() - aliases = [] - for name, value in self.cleaned_data.iteritems(): - if not name.startswith("aliases"): - continue - if value == "": - continue - aliases.append(value) - - for alias in self.mb.alias_set.all(): - if not alias.full_address in aliases: - if len(alias.get_recipients()) >= 2: - continue - alias.delete() - else: - aliases.remove(alias.full_address) - if aliases: - events.raiseEvent( - "CanCreate", user, "mailbox_aliases", len(aliases) - ) - for alias in aliases: - local_part, domname = split_mailbox(alias) - try: - self.mb.alias_set.get(address=local_part, domain__name=domname) - except Alias.DoesNotExist: - pass - else: - continue - al = Alias(address=local_part, enabled=account.is_active) - al.domain = Domain.objects.get(name=domname) - al.save(int_rcpts=[self.mb], creator=user) + self._update_aliases() return self.mb diff --git a/modoboa/extensions/admin/forms/domain.py b/modoboa/extensions/admin/forms/domain.py index 3c5e7ab39..e9efb0c78 100644 --- a/modoboa/extensions/admin/forms/domain.py +++ b/modoboa/extensions/admin/forms/domain.py @@ -216,6 +216,7 @@ class DomainFormOptions(forms.Form): self.fields["create_aliases"].required = True def clean_dom_admin_username(self): + """Ensure admin username is an email address.""" if '@' in self.cleaned_data["dom_admin_username"]: raise forms.ValidationError(_("Invalid format")) return self.cleaned_data["dom_admin_username"] @@ -225,7 +226,8 @@ class DomainFormOptions(forms.Form): return if self.cleaned_data["create_dom_admin"] == "no": return - username = "%s@%s" % (self.cleaned_data["dom_admin_username"], domain.name) + username = "%s@%s" % ( + self.cleaned_data["dom_admin_username"], domain.name) try: da = User.objects.get(username=username) except User.DoesNotExist: @@ -238,15 +240,18 @@ class DomainFormOptions(forms.Form): da.save() da.set_role("DomainAdmins") da.post_create(user) - mb = Mailbox(address=self.cleaned_data["dom_admin_username"], domain=domain, - user=da, use_domain_quota=True) + mb = Mailbox( + address=self.cleaned_data["dom_admin_username"], domain=domain, + user=da, use_domain_quota=True + ) mb.set_quota(override_rules=user.has_perm("admin.change_domain")) mb.save(creator=user) if self.cleaned_data["create_aliases"] == "yes": events.raiseEvent("CanCreate", user, "mailbox_aliases") - al = Alias(address="postmaster", domain=domain, enabled=True) - al.save(int_rcpts=[mb], creator=user) + alias = Alias(address="postmaster", domain=domain, enabled=True) + alias.save(int_rcpts=[mb]) + alias.post_create(user) domain.add_admin(da) diff --git a/modoboa/extensions/admin/models/alias.py b/modoboa/extensions/admin/models/alias.py index 64d587b9f..cbc6879c9 100644 --- a/modoboa/extensions/admin/models/alias.py +++ b/modoboa/extensions/admin/models/alias.py @@ -93,6 +93,7 @@ class Alias(AdminObject): grant_access_to_object(admin, self) def save(self, *args, **kwargs): + """Custom save method.""" if 'ext_rcpts' in kwargs: self.extmboxes = ",".join(kwargs['ext_rcpts']) \ if kwargs['ext_rcpts'] else "" @@ -105,19 +106,19 @@ class Alias(AdminObject): super(Alias, self).save(*args, **kwargs) curaliases = self.aliases.all() curmboxes = self.mboxes.all() - for t in int_rcpts: - if isinstance(t, Alias): - if not t in curaliases: - self.aliases.add(t) + for rcpt in int_rcpts: + if isinstance(rcpt, Alias): + if not rcpt in curaliases: + self.aliases.add(rcpt) continue - if not t in curmboxes: - self.mboxes.add(t) - for t in curaliases: - if not t in int_rcpts: - self.aliases.remove(t) - for t in curmboxes: - if not t in int_rcpts: - self.mboxes.remove(t) + if not rcpt in curmboxes: + self.mboxes.add(rcpt) + for rcpt in curaliases: + if not rcpt in int_rcpts: + self.aliases.remove(rcpt) + for rcpt in curmboxes: + if not rcpt in int_rcpts: + self.mboxes.remove(rcpt) def get_recipients(self, with_external=True): """Return the recipients list. diff --git a/modoboa/extensions/admin/templates/admin/aliasform.html b/modoboa/extensions/admin/templates/admin/aliasform.html index 6cbc5b75f..5eef9092c 100644 --- a/modoboa/extensions/admin/templates/admin/aliasform.html +++ b/modoboa/extensions/admin/templates/admin/aliasform.html @@ -2,7 +2,7 @@ {% block formbody %} {% render_field form.email %} {% render_fields_group form "recipients" %} -{% if form.instance.domain.enabled %} +{% if not form.instance.pk or form.instance.domain.enabled %} {% render_field form.enabled %} {% else %} {% include "common/generic_field.html" with field=form.enabled label_width="col-sm-4" inline_message=_("Domain is disabled") extra_classes="info" %} diff --git a/modoboa/extensions/admin/templates/admin/mailform.html b/modoboa/extensions/admin/templates/admin/mailform.html index d00bf3359..4b7b6ec63 100644 --- a/modoboa/extensions/admin/templates/admin/mailform.html +++ b/modoboa/extensions/admin/templates/admin/mailform.html @@ -1,5 +1,5 @@ {% load form_tags %} {% render_field form.email %} -{% include "common/generic_field.html" with field=form.quota help_display_mode="tooltip" label_width="col-sm-4" appended_text=_("MB") activator=True activator_value=form.quota_act.value deactivate_if_empty=False activator_label=_("Use domain default value") %} +{% include "common/generic_field.html" with field=form.quota help_display_mode="tooltip" label_width="col-sm-4" appended_text=_("MB") activator=True activator_value=form.quota_act.value deactivate_if_empty=False activator_label=_("Use domain default value") activator_size="col-sm-5" %} {% render_extra_fields form %} {% render_fields_group form "aliases" %} diff --git a/modoboa/extensions/amavis/app_settings.py b/modoboa/extensions/amavis/app_settings.py index 00a099c4e..2fa1957cf 100644 --- a/modoboa/extensions/amavis/app_settings.py +++ b/modoboa/extensions/amavis/app_settings.py @@ -1,11 +1,16 @@ # coding: utf-8 -from django.utils.translation import ugettext_lazy as _ + from django import forms -from modoboa.lib.parameters import AdminParametersForm, UserParametersForm +from django.utils.translation import ugettext_lazy as _ + from modoboa.lib.formutils import SeparatorField, YesNoField, InlineRadioSelect +from modoboa.lib.parameters import AdminParametersForm, UserParametersForm class ParametersForm(AdminParametersForm): + + """Extension settings.""" + app = "amavis" qsettings_sep = SeparatorField(label=_("Quarantine settings")) @@ -13,8 +18,9 @@ class ParametersForm(AdminParametersForm): max_messages_age = forms.IntegerField( label=_("Maximum message age"), initial=14, - help_text=_("Quarantine messages maximum age (in days) before deletion"), - widget=forms.TextInput(attrs={"class": "form-control"}) + help_text=_( + "Quarantine messages maximum age (in days) before deletion" + ) ) sep1 = SeparatorField(label=_("Messages releasing")) @@ -22,7 +28,8 @@ class ParametersForm(AdminParametersForm): released_msgs_cleanup = YesNoField( label=_("Remove released messages"), initial="no", - help_text=_("Remove messages marked as released while cleaning up the database") + help_text=_("Remove messages marked as released while cleaning up " + "the database") ) am_pdp_mode = forms.ChoiceField( @@ -43,15 +50,13 @@ class ParametersForm(AdminParametersForm): am_pdp_port = forms.IntegerField( label=_("PDP server port"), initial=9998, - help_text=_("PDP server port (if inet mode)"), - widget=forms.TextInput(attrs={"class": "form-control"}) + help_text=_("PDP server port (if inet mode)") ) am_pdp_socket = forms.CharField( label=_("PDP server socket"), initial="/var/amavis/amavisd.sock", - help_text=_("Path to the PDP server socket (if unix mode)"), - widget=forms.TextInput(attrs={"class": "form-control"}) + help_text=_("Path to the PDP server socket (if unix mode)") ) user_can_release = YesNoField( @@ -69,18 +74,83 @@ class ParametersForm(AdminParametersForm): notifications_sender = forms.EmailField( label=_("Notifications sender"), initial="notification@modoboa.org", - help_text=_("The e-mail address used to send notitications"), - widget=forms.TextInput(attrs={"class": "form-control"}) + help_text=_("The e-mail address used to send notitications") + ) + + lsep = SeparatorField(label=_("Manual learning")) + + manual_learning = YesNoField( + label=_("Enable manual learning"), + initial="yes", + help_text=_( + "Allow super administrators to manually train Spamassassin" + ) + ) + + sa_is_local = YesNoField( + label=_("Is Spamassassin local?"), + initial="yes", + help_text=_( + "Tell if Spamassassin is running on the same server than modoboa" + ) + ) + + default_user = forms.CharField( + label=_("Default user"), + initial="amavis", + help_text=_( + "Name of the user owning the default bayesian database" + ) + ) + + spamd_address = forms.CharField( + label=_("Spamd address"), + initial="127.0.0.1", + help_text=_("The IP address where spamd can be reached") + ) + + spamd_port = forms.IntegerField( + label=_("Spamd port"), + initial=783, + help_text=_("The TCP port spamd is listening on") + ) + + domain_level_learning = YesNoField( + label=_("Enable per-domain manual learning"), + initial="no", + help_text=_( + "Allow domain administrators to train Spamassassin " + "(within dedicated per-domain databases)" + ) + ) + + user_level_learning = YesNoField( + label=_("Enable per-user manual learning"), + initial="no", + help_text=_( + "Allow simple users to personally train Spamassassin " + "(within a dedicated database)" + ) ) visibility_rules = { "am_pdp_host": "am_pdp_mode=inet", "am_pdp_port": "am_pdp_mode=inet", - "am_pdp_socket": "am_pdp_mode=unix" + "am_pdp_socket": "am_pdp_mode=unix", + + "sa_is_local": "manual_learning=yes", + "default_user": "manual_learning=yes", + "spamd_address": "sa_is_local=no", + "spamd_port": "sa_is_local=no", + "domain_level_learning": "manual_learning=yes", + "user_level_learning": "manual_learning=yes" } class UserSettings(UserParametersForm): + + """Per-user settings.""" + app = "amavis" dsep = SeparatorField(label=_("Display")) @@ -88,6 +158,5 @@ class UserSettings(UserParametersForm): messages_per_page = forms.IntegerField( initial=40, label=_("Number of displayed emails per page"), - help_text=_("Set the maximum number of messages displayed in a page"), - widget=forms.TextInput(attrs={"class": "form-control"}) + help_text=_("Set the maximum number of messages displayed in a page") ) diff --git a/modoboa/extensions/amavis/forms.py b/modoboa/extensions/amavis/forms.py index 7d9cc82cb..72be441e9 100644 --- a/modoboa/extensions/amavis/forms.py +++ b/modoboa/extensions/amavis/forms.py @@ -1,7 +1,14 @@ # coding: utf-8 +""" +Amavis forms. +""" + from django import forms +from django.utils.translation import ugettext as _ + +from modoboa.lib import parameters from modoboa.lib.formutils import InlineRadioSelect -from modoboa.extensions.amavis.models import Policy, Users +from .models import Policy, Users class DomainPolicyForm(forms.ModelForm): @@ -57,3 +64,34 @@ class DomainPolicyForm(forms.ModelForm): u.policy = policy policy.save() return policy + + +class LearningRecipientForm(forms.Form): + + """A form to select the recipient of a learning request.""" + + recipient = forms.ChoiceField( + label=None, choices=[] + ) + ltype = forms.ChoiceField( + label="", choices=[("spam", "spam"), ("ham", "ham")], + widget=forms.widgets.HiddenInput + ) + selection = forms.CharField( + label="", widget=forms.widgets.HiddenInput) + + def __init__(self, user, *args, **kwargs): + """Constructor.""" + super(LearningRecipientForm, self).__init__(*args, **kwargs) + choices = [] + if user.group == "SuperAdmins": + choices.append(("global", _("Global database"))) + domain_level_learning = parameters.get_admin( + "DOMAIN_LEVEL_LEARNING") == "yes" + user_level_learning = parameters.get_admin( + "USER_LEVEL_LEARNING") == "yes" + if domain_level_learning: + choices.append(("domain", _("Domain's database"))) + if user_level_learning: + choices.append(("user", _("User's database"))) + self.fields["recipient"].choices = choices diff --git a/modoboa/extensions/amavis/general_callbacks.py b/modoboa/extensions/amavis/general_callbacks.py index 4ec602842..7bca1b8e1 100644 --- a/modoboa/extensions/amavis/general_callbacks.py +++ b/modoboa/extensions/amavis/general_callbacks.py @@ -8,6 +8,8 @@ from modoboa.extensions.amavis.lib import ( create_user_and_policy, update_user_and_policy, delete_user_and_policy, create_user_and_use_policy, delete_user ) +from .lib import manual_learning_enabled +from .models import Policy, Users @events.observe("UserMenuDisplay") @@ -23,22 +25,27 @@ def menu(target, user): @events.observe("DomainCreated") def on_domain_created(user, domain): - create_user_and_policy(domain.name) + create_user_and_policy("@{0}".format(domain.name)) @events.observe("DomainModified") def on_domain_modified(domain): - update_user_and_policy(domain.oldname, domain.name) + update_user_and_policy( + "@{0}".format(domain.oldname), + "@{0}".format(domain.name) + ) @events.observe("DomainDeleted") def on_domain_deleted(domain): - delete_user_and_policy(domain.name) + delete_user_and_policy("@{0}".format(domain.name)) @events.observe("DomainAliasCreated") def on_domain_alias_created(user, domainalias): - create_user_and_use_policy(domainalias.name, domainalias.target.name) + create_user_and_use_policy( + "@{0}".format(domainalias.name), domainalias.target.name + ) @events.observe("DomainAliasDeleted") @@ -46,7 +53,60 @@ def on_domain_alias_deleted(domainaliases): if isinstance(domainaliases, DomainAlias): domainaliases = [domainaliases] for domainalias in domainaliases: - delete_user(domainalias.name) + delete_user("@{0}".format(domainalias.name)) + + +@events.observe("MailboxModified") +def on_mailbox_modified(mailbox): + """Update amavis records if address has changed.""" + if parameters.get_admin("MANUAL_LEARNING") == "no" or \ + mailbox.full_address == mailbox.old_full_address: + return + user = Users.objects.select_related.get(email=mailbox.old_full_address) + full_address = mailbox.full_address + user.email = full_address + user.policy.policy_name = full_address[:32] + user.policy.sa_username = full_address + user.policy.save() + user.save() + + +@events.observe("MailboxDeleted") +def on_mailbox_deleted(mailbox): + """Clean amavis database when a mailbox is removed.""" + if parameters.get_admin("MANUAL_LEARNING") == "no": + return + delete_user_and_policy("@{0}".format(mailbox.full_address)) + + +@events.observe("MailboxAliasCreated") +def on_mailboxalias_created(user, alias): + """Create amavis record for the new alias. + + FIXME: how to deal with distibution lists ? + """ + if not manual_learning_enabled(user) or alias.type != "alias": + return + try: + policy = Policy.objects.get( + policy_name=alias.mboxes.all()[0].full_address + ) + except Policy.DoesNotExist: + return + else: + email = alias.full_address + Users.objects.create( + email=email, policy=policy, fullname=email, priority=7 + ) + + +@events.observe("MailboxAliasDeleted") +def on_mailboxalias_deleted(alias): + """Clean amavis database when an alias is removed.""" + if parameters.get_admin("MANUAL_LEARNING") == "no": + return + if Users.objects.exists(email=alias.fullname): + Users.objects.delete(email=alias.fullname) @events.observe("GetStaticContent") diff --git a/modoboa/extensions/amavis/lib.py b/modoboa/extensions/amavis/lib.py index 0fcd1fb18..646ca46c8 100644 --- a/modoboa/extensions/amavis/lib.py +++ b/modoboa/extensions/amavis/lib.py @@ -1,15 +1,21 @@ # coding: utf-8 -import socket -import re -import struct -import string + from functools import wraps -from django.core.urlresolvers import reverse +import re +import socket +import string +import struct + from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse + +from modoboa.extensions.admin.models import Mailbox, Alias from modoboa.lib import parameters +from modoboa.lib.emailutils import split_mailbox from modoboa.lib.exceptions import InternalError +from modoboa.lib.sysutils import exec_cmd from modoboa.lib.webutils import NavigationParameters -from modoboa.extensions.amavis.models import Users, Policy +from .models import Users, Policy def selfservice(ssfunc=None): @@ -80,6 +86,100 @@ recipient=%s return False +class SpamassassinClient(object): + + """A stupid spamassassin client.""" + + def __init__(self, user, recipient_db): + """Constructor.""" + self._sa_is_local = parameters.get_admin("SA_IS_LOCAL") + self._default_username = parameters.get_admin("DEFAULT_USER") + self._recipient_db = recipient_db + self._setup_cache = {} + self._username_cache = [] + if user.group == "SimpleUsers": + user_level_learning = parameters.get_admin("USER_LEVEL_LEARNING") + if user_level_learning == "yes": + self._username = user.email + else: + self._username = None + self.error = None + if self._sa_is_local == "yes": + self._learn_cmd = "sa-learn --{0} --no-sync -u {1}" + self._learn_cmd_kwargs = {} + self._expected_exit_codes = [0] + else: + self._learn_cmd = "spamc -d {0} -p {1}".format( + parameters.get_admin("SPAMD_ADDRESS"), + parameters.get_admin("SPAMD_PORT") + ) + self._learn_cmd += " -L {0} -u {1}" + self._learn_cmd_kwargs = {} + self._expected_exit_codes = [5, 6] + + def _get_mailbox_from_rcpt(self, rcpt): + """Retrieve a mailbox from a recipient address.""" + local_part, domname = split_mailbox(rcpt) + try: + mailbox = Mailbox.objects.select_related("domain").get( + address=local_part, domain__name=domname) + except Mailbox.DoesNotExist: + try: + alias = Alias.objects.select_related("domain").get( + address=local_part, domain__name=domname) + except Alias.DoesNotExist: + raise InternalError(_("No recipient found")) + if alias.type != "alias": + return None + mailbox = alias.mboxes.all()[0] + return mailbox + + def _learn(self, rcpt, msg, mtype): + """Internal method to call the learning command.""" + if self._username is None: + if self._recipient_db == "global": + username = self._default_username + else: + mbox = self._get_mailbox_from_rcpt(rcpt) + if mbox is None: + username = self._default_username + if self._recipient_db == "domain": + username = mbox.domain.name + if username not in self._setup_cache and \ + setup_manual_learning_for_domain(mbox.domain): + self._setup_cache[username] = True + else: + username = mbox.full_address + if username not in self._setup_cache and \ + setup_manual_learning_for_mbox(mbox): + self._setup_cache[username] = True + else: + username = self._username + if username not in self._username_cache: + self._username_cache.append(username) + cmd = self._learn_cmd.format(mtype, username) + code, output = exec_cmd(cmd, pinput=msg, **self._learn_cmd_kwargs) + if code in self._expected_exit_codes: + return True + self.error = output + return False + + def learn_spam(self, rcpt, msg): + """Learn new spam.""" + return self._learn(rcpt, msg, "spam") + + def learn_ham(self, rcpt, msg): + """Learn new ham.""" + return self._learn(rcpt, msg, "ham") + + def done(self): + """Call this method at the end of the processing.""" + if self._sa_is_local: + for username in self._username_cache: + exec_cmd("sa-learn -u {0} --sync".format(username), + **self._learn_cmd_kwargs) + + class QuarantineNavigationParameters(NavigationParameters): """ Specific NavigationParameters subclass for the quarantine. @@ -89,7 +189,10 @@ class QuarantineNavigationParameters(NavigationParameters): request, 'quarantine_navparams' ) self.parameters += [ - ('msgtype', None, False), ('viewrequests', None, False) + ('pattern', '', False), + ('criteria', 'from_addr', False), + ('msgtype', None, False), + ('viewrequests', None, False) ] def _store_page(self): @@ -123,31 +226,31 @@ class QuarantineNavigationParameters(NavigationParameters): return url -def create_user_and_policy(name): +def create_user_and_policy(name, priority=7): """Create records. Create two records (a user and a policy) using :keyword:`name` as an identifier. :param str name: name + :return: the new ``Policy`` object """ policy = Policy.objects.create(policy_name=name[:32]) Users.objects.create( - email="@%s" % name, fullname=name, - priority=7, policy=policy + email=name, fullname=name, priority=priority, policy=policy ) + return policy -def create_user_and_use_policy(name, policy_name): +def create_user_and_use_policy(name, policy_name, priority=7): """Create a *users* record and use an existing policy. :param str name: user record name :param str policy_name: policy name """ - policy = Policy.objects.get(policy_name=policy_name[:32]) + policy = Policy.objects.get(policy_name="@{0}".format(policy_name[:32])) Users.objects.create( - email="@%s" % name, fullname=name, - priority=7, policy=policy + email=name, fullname=name, priority=priority, policy=policy ) @@ -159,8 +262,8 @@ def update_user_and_policy(oldname, newname): """ if oldname == newname: return - u = Users.objects.get(email="@%s" % oldname) - u.email = "@%s" % newname + u = Users.objects.get(email=oldname) + u.email = newname u.fullname = newname u.policy.policy_name = newname[:32] u.policy.save() @@ -173,7 +276,7 @@ def delete_user_and_policy(name): :param str name: identifier """ try: - u = Users.objects.get(email="@%s" % name) + u = Users.objects.get(email=name) except Users.DoesNotExist: return u.policy.delete() @@ -186,6 +289,53 @@ def delete_user(name): :param str name: user record name """ try: - Users.objects.get(email="@%s" % name).delete() + Users.objects.get(email=name).delete() except Users.DoesNotExist: pass + + +def manual_learning_enabled(user): + """Check if manual learning is enabled or not. + + Also check for :kw:`user` if necessary. + + :return: True if learning is enabled, False otherwise. + """ + manual_learning = parameters.get_admin("MANUAL_LEARNING") == "yes" + if manual_learning and user.group != 'SuperAdmins': + domain_level_learning = parameters.get_admin( + "DOMAIN_LEVEL_LEARNING") == "yes" + user_level_learning = parameters.get_admin( + "USER_LEVEL_LEARNING") == "yes" + if user.has_perm("admin.view_domains"): + manual_learning = domain_level_learning or user_level_learning + else: + manual_learning = user_level_learning + return manual_learning + + +def setup_manual_learning_for_domain(domain): + """Setup manual learning if necessary. + + :return: True if learning has been setup, False otherwise + """ + if Policy.objects.filter(sa_username=domain.name).count(): + return False + policy = Policy.objects.get(policy_name=domain.name) + policy.sa_username = domain.name + policy.save() + return True + + +def setup_manual_learning_for_mbox(mbox): + """Setup manual learning if necessary. + + :return: True if learning has been setup, False otherwise + """ + result = False + if not Policy.objects.filter(policy_name=mbox.full_address).exists(): + policy = create_user_and_policy(mbox.full_address) + for alias in mbox.alias_addresses: + create_user_and_use_policy(alias, policy) + result = True + return result diff --git a/modoboa/extensions/amavis/sql_connector.py b/modoboa/extensions/amavis/sql_connector.py index 9a27c0a1c..46f0075ae 100644 --- a/modoboa/extensions/amavis/sql_connector.py +++ b/modoboa/extensions/amavis/sql_connector.py @@ -7,7 +7,6 @@ from django.db.models import Q from modoboa.extensions.admin.models import Domain from modoboa.lib.dbutils import db_type -from modoboa.lib.webutils import static_url from .models import Quarantine, Msgrcpt, Maddr @@ -92,7 +91,7 @@ class SQLconnector(object): Filters: rs, rid, content """ - flt = Q(rs__in=[' ', 'V', 'R', 'p']) \ + flt = Q(rs__in=[' ', 'V', 'R', 'p', 'S', 'H']) \ if self.navparams.get('viewrequests', '0') != '1' else Q(rs='p') flt = self._apply_msgrcpt_filters(flt) pattern = self.navparams.get("pattern", "") @@ -122,7 +121,7 @@ class SQLconnector(object): mail__in=Quarantine.objects.filter(chunk_ind=1).values("mail_id") ) - messages = Msgrcpt.objects.select_related().filter(flt) + messages = Msgrcpt.objects.select_related("mail", "rid").filter(flt) messages = self._apply_extra_select_filters(messages) return messages @@ -156,21 +155,21 @@ class SQLconnector(object): """ emails = [] for qm in self.messages[start - 1:stop]: - m = {"from": qm["mail__from_addr"], - "to": qm["rid__email"], - "subject": qm["mail__subject"], - "mailid": qm["mail__mail_id"], - "date": datetime.datetime.fromtimestamp(qm["mail__time_num"]), - "type": qm["content"], - "score": qm["bspam_level"]} - rs = qm["rs"] - if rs == 'D': + if qm["rs"] == 'D': continue - elif rs in ['', ' ']: + m = { + "from": qm["mail__from_addr"], + "to": qm["rid__email"], + "subject": qm["mail__subject"], + "mailid": qm["mail__mail_id"], + "date": datetime.datetime.fromtimestamp(qm["mail__time_num"]), + "type": qm["content"], + "score": qm["bspam_level"], + "status": qm["rs"] + } + if qm["rs"] in ['', ' ']: m["class"] = "unseen" - elif rs == 'R': - m["img_rstatus"] = static_url("pics/release.png") - elif rs == 'p': + elif qm["rs"] == 'p': m["class"] = "pending" emails.append(m) return emails @@ -222,6 +221,11 @@ class PgSQLconnector(SQLconnector): Make use of ``QuerySet.extra`` and postgres ``convert_from`` function to let the quarantine manager work as expected ! + + The T4 alias is not a random choice. The generated query was + dumped to found it. Be careful since it can changes with + future Django versions... + """ def _apply_msgrcpt_filters(self, flt): @@ -246,11 +250,13 @@ class PgSQLconnector(SQLconnector): def _apply_extra_search_filter(self, crit, pattern): """Apply search filters using additional criterias. + """ if crit == "to": self._where.append( - "convert_from(maddr.email, 'UTF8') LIKE '%%%s%%'" - % pattern) + "convert_from(maddr.email, 'UTF8') LIKE '%%{0}%%'".format( + pattern) + ) return None def _apply_extra_select_filters(self, messages): diff --git a/modoboa/extensions/amavis/static/amavis/js/quarantine.js b/modoboa/extensions/amavis/static/amavis/js/quarantine.js index 3350fc8a2..2885c28cb 100644 --- a/modoboa/extensions/amavis/static/amavis/js/quarantine.js +++ b/modoboa/extensions/amavis/static/amavis/js/quarantine.js @@ -93,10 +93,18 @@ Quarantine.prototype = { $.proxy(this.release_selection, this)); $(document).on("click", "a[name=delete-multi]", $.proxy(this.delete_selection, this)); + $(document).on("click", "a[name=mark-as-spam-multi]", + $.proxy(this.mark_selection_as_spam, this)); + $(document).on("click", "a[name=mark-as-ham-multi]", + $.proxy(this.mark_selection_as_ham, this)); $(document).on("click", "a[name=viewrequests]", $.proxy(this.view_requests, this)); $(document).on("click", "a[name=release]", $.proxy(this.release, this)); $(document).on("click", "a[name=delete]", $.proxy(this.delete, this)); + $(document).on("click", "a[name=mark-as-spam]", + $.proxy(this.mark_as_spam, this)); + $(document).on("click", "a[name=mark-as-ham]", + $.proxy(this.mark_as_ham, this)); $(document).on("click", "a[name=headers]", $.proxy(this.headers, this)); $(document).on("click", "td[name=type] span", $.proxy(this.filter_by_type, this)); $(document).on("click", "#filters label", $.proxy(this.filter_by_type, this)); @@ -214,14 +222,12 @@ Quarantine.prototype = { this.navobj.update(); }, - _send_selection: function(e, name, message) { - var $link = $(e.target); - - e.preventDefault(); - if (!this.htmltable.current_selection().length || - !confirm(message)) { - return; - } + /** + * Return the list of currently selected messages. + * + * @return {Array} - list of message IDs + */ + get_current_selection: function() { var selection = []; this.htmltable.current_selection().each(function() { @@ -234,12 +240,30 @@ Quarantine.prototype = { selection.push($tr.attr("id")); } }); + return selection; + }, + + _send_selection: function(e, name, message) { + var $link = $(e.target); + + e.preventDefault(); + if (!this.htmltable.current_selection().length || + !confirm(message)) { + return; + } + var selection = this.get_current_selection(); + $.ajax({ url: $link.attr("href"), data: "action=" + name + "&selection=" + selection.join(","), type: 'POST', dataType: 'json' - }).done($.proxy(this.action_cb, this)); + }).done( + $.proxy(this.action_cb, this) + ).fail(function(jqxhr) { + var data = $.parseJSON(jqxhr.responseText); + $("body").notify("error", data.message); + }); }, release_selection: function(e) { @@ -250,6 +274,62 @@ Quarantine.prototype = { this._send_selection(e, "delete", gettext("Delete this selection?")); }, + /** + * Show the recipient selection form inside a modal box. + * + * @param {Object} evt - event object + * @param {string} ltype - learning type (spam or ham) + */ + show_select_rcpt_form: function(evt, ltype, selection) { + if (selection === undefined) { + selection = this.get_current_selection(); + } + var url = this.options.learning_recipient_url + + "?type=" + ltype + "&selection=" + + selection.join(","); + var $this = this; + + modalbox(evt, null, url, function() { + $(".submit").on("click", function(evt) { + simple_ajax_form_post(evt, { + formid: "learning_recipient_form", + reload_on_success: false, + success_cb: $.proxy($this.action_cb, $this) + }); + }); + }); + }, + + /** + * Mark the current selection as spam. + * + * @param {Object} e - event object + */ + mark_selection_as_spam: function(e) { + if (this.options.check_learning_rcpt && + this.htmltable.current_selection().length) { + this.show_select_rcpt_form(e, "spam"); + } else { + this._send_selection( + e, "mark_as_spam", gettext("Mark this selection as spam?")); + } + }, + + /** + * Mark the current selection as ham. + * + * @param {Object} e - event object + */ + mark_selection_as_ham: function(e) { + if (this.options.check_learning_rcpt && + this.htmltable.current_selection().length) { + this.show_select_rcpt_form(e, "ham"); + } else { + this._send_selection( + e, "mark_as_ham", gettext("Mark this selection as non-spam?")); + } + }, + _send_action: function(e, message) { var $link = get_target(e, "a"); @@ -262,10 +342,15 @@ Quarantine.prototype = { dataType: 'json', type: 'POST', data: {rcpt: get_parameter_by_name($link.attr("href"), 'rcpt')} - }).done($.proxy(this.action_cb, this)); + }).done( + $.proxy(this.action_cb, this) + ).fail(function(jqxhr) { + var data = $.parseJSON(jqxhr.responseText); + $("body").notify("error", data.message); + }); }, - release: function(e) { + release : function(e) { this._send_action(e, gettext("Release this message?")); }, @@ -273,6 +358,38 @@ Quarantine.prototype = { this._send_action(e, gettext("Delete this message?")); }, + /** + * Mark the current message as spam. + * + * @param {Object} e - event object + */ + mark_as_spam: function(e) { + if (this.options.check_learning_rcpt) { + var $link = get_target(e, "a"); + var selection = get_parameter_by_name($link.attr("href"), "rcpt") + + " " + $link.attr("data-mail-id"); + this.show_select_rcpt_form(e, "spam", [selection]); + } else { + this._send_action(e, gettext("Mark as spam?")); + } + }, + + /** + * Mark the current message as ham. + * + * @param {Object} e - event object + */ + mark_as_ham: function(e) { + if (this.options.check_learning_rcpt) { + var $link = get_target(e, "a"); + var selection = get_parameter_by_name($link.attr("href"), "rcpt") + + " " + $link.attr("data-mail-id"); + this.show_select_rcpt_form(e, "ham", [selection]); + } else { + this._send_action(e, gettext("Mark as non-spam?")); + } + }, + show_rawheaders: function($mailcontent) { $mailcontent.find("#rawheaders").removeClass("hidden"); $mailcontent.find("#emailheaders").addClass("hidden"); @@ -320,15 +437,13 @@ Quarantine.prototype = { }, activate_buttons: function($tr) { - $("a[name=release-multi]").removeClass('disabled'); - $("a[name=delete-multi]").removeClass('disabled'); + $("a[name*=-multi]").removeClass('disabled'); $("#selectall").prop('checked', true); }, deactivate_buttons: function($tr) { if (!this.htmltable || !this.htmltable.current_selection().length) { - $("a[name=release-multi]").addClass('disabled'); - $("a[name=delete-multi]").addClass('disabled'); + $("a[name*=-multi]").addClass('disabled'); $("#selectall").prop('checked', false); } }, @@ -361,12 +476,13 @@ Quarantine.prototype = { }, action_cb: function(data) { - if (data.status == "ok") { - this.navobj.parse_string(data.url, true).update(true); - $("body").notify("success", data.respmsg, 2000); - } else { - $("body").notify("error", data.respmsg); + if (data.reload) { + this.navobj.update(true); } + if (data.url) { + this.navobj.parse_string(data.url, true).update(true); + } + $("body").notify("success", data.message, 2000); } }; diff --git a/modoboa/extensions/amavis/templates/amavis/domain_content_filter.html b/modoboa/extensions/amavis/templates/amavis/domain_content_filter.html index b15a6390b..d669b7824 100644 --- a/modoboa/extensions/amavis/templates/amavis/domain_content_filter.html +++ b/modoboa/extensions/amavis/templates/amavis/domain_content_filter.html @@ -14,5 +14,5 @@ {% trans "or more throw spam message away" %} -{% render_field form.spam_subject_tag2 activator=True activator_label=_("default") %} +{% render_field form.spam_subject_tag2 activator=True activator_label=_("default") activator_size="col-sm-3" %} {% render_field form.bypass_banned_checks %} diff --git a/modoboa/extensions/amavis/templates/amavis/emails_page.html b/modoboa/extensions/amavis/templates/amavis/emails_page.html index b534a8d46..1b1233c6e 100644 --- a/modoboa/extensions/amavis/templates/amavis/emails_page.html +++ b/modoboa/extensions/amavis/templates/amavis/emails_page.html @@ -1,4 +1,6 @@ +{% load i18n %} {% load amavis_tags %} +{% load static %} {% for email in email_list %} @@ -9,7 +11,13 @@ {{ email.type|msgtype_to_html }} - {% if email.img_rstatus %}{% endif %} + {% if email.status == 'R' %} + + {% elif email.status == 'S' %} + + {% elif email.status == 'H' %} + + {% endif %} {{ email.score }} {{ email.to }} diff --git a/modoboa/extensions/amavis/templates/amavis/index.html b/modoboa/extensions/amavis/templates/amavis/index.html index d1ebfbe4a..70a1a2ee7 100644 --- a/modoboa/extensions/amavis/templates/amavis/index.html +++ b/modoboa/extensions/amavis/templates/amavis/index.html @@ -21,7 +21,9 @@ $(document).ready(function() { var qm = new Quarantine({ deflocation: "listing/?sort_order=-date&reset_page=true", defcallback: "listing_cb", - load_page_url: "{% url 'amavis:mail_page' %}" + load_page_url: "{% url 'amavis:mail_page' %}", + check_learning_rcpt: {{ check_learning_rcpt }}, + learning_recipient_url: "{% url 'amavis:learning_recipient_set' %}" }); }); @@ -29,8 +31,7 @@ $(document).ready(function() { {% block container_content %} -
{% endblock %} diff --git a/modoboa/extensions/amavis/templates/amavis/main_action_bar.html b/modoboa/extensions/amavis/templates/amavis/main_action_bar.html index 06375ff72..ccea6116b 100644 --- a/modoboa/extensions/amavis/templates/amavis/main_action_bar.html +++ b/modoboa/extensions/amavis/templates/amavis/main_action_bar.html @@ -1,13 +1,14 @@ {% load lib_tags i18n %}
-
+ +
+ {% if manual_learning %} + + {% endif %} -
+
+ +
{% include "common/email_searchbar.html" with extraopts=extraopts %}
-
+
{% if activator %} -
+