From e0488cfb5d8d47a217152c9db5c8749bbf6f5e20 Mon Sep 17 00:00:00 2001 From: Jesse Griffin Date: Wed, 19 Mar 2014 15:33:47 -0600 Subject: [PATCH 01/18] Update plugin_api.rst Import for ModoExtension was needs to be from modoboa.core.extensions. --- doc/extending/plugin_api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/extending/plugin_api.rst b/doc/extending/plugin_api.rst index a96381405..46ee7fe12 100644 --- a/doc/extending/plugin_api.rst +++ b/doc/extending/plugin_api.rst @@ -41,7 +41,7 @@ Then, you need to register this application using the provided API. Just copy/paste the following example into the :file:`__init__.py` file of the future extension:: - from modoboa.extensions import ModoExtension, exts_pool + from modoboa.core.extensions import ModoExtension, exts_pool class MyExtension(ModoExtension): name = "myext" From c2c0a514db504383ce9b98bceeaa11fbbf5df20d Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 21 Mar 2014 09:10:45 +0100 Subject: [PATCH 02/18] Documentation fixes. --- doc/getting_started/plugins.rst | 20 ++++++++++---------- doc/integration/imap_and_smtp.rst | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/doc/getting_started/plugins.rst b/doc/getting_started/plugins.rst index 9fbdb0130..61556029e 100644 --- a/doc/getting_started/plugins.rst +++ b/doc/getting_started/plugins.rst @@ -58,16 +58,16 @@ Replace values between ``<>`` by yours. Edit the :file:`/etc/postfix/main.cf` file and copy the following lines inside:: - relay_domains = :/etc/postfix/maps/sql-relaydomains.cf + relay_domains = :/etc/postfix/sql-relaydomains.cf transport_maps = - :/etc/postfix/maps/sql-relaydomains-transport.cf - :/etc/postfix/maps/sql-relaydomain-aliases-transport.cf + :/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/maps/sql-relay-recipient-verification.cf + :/etc/postfix/sql-relay-recipient-verification.cf Replace ```` by the name of the database you use. @@ -332,11 +332,11 @@ configuration files as follows: ``/etc/postfix/main.cf``:: - transport_maps = mysql:/etc/postfix/maps/sql-autoreplies-transport.cf - virtual_alias_maps = :/etc/postfix/maps/sql-aliases.cf - :/etc/postfix/mapfiles/sql-domain-aliases-mailboxes.cf, - :/etc/postfix/maps/sql-autoreplies.cf, - :/etc/postfix/mapfiles/sql-catchall-aliases.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:: @@ -345,7 +345,7 @@ configuration files as follows: ``/etc/postfix/master.cf``:: autoreply unix - n n - - pipe - flags= user=vmail: argv=/manage.py autoreply $sender $mailbox + 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. diff --git a/doc/integration/imap_and_smtp.rst b/doc/integration/imap_and_smtp.rst index 79ab8e016..b0f0df61c 100644 --- a/doc/integration/imap_and_smtp.rst +++ b/doc/integration/imap_and_smtp.rst @@ -28,19 +28,19 @@ Then, edit the ``inbox`` namespace and add the following lines:: inbox = yes mailbox Drafts { - auto = create + auto = subscribe special_use = \Drafts } mailbox Junk { - auto = create + auto = subscribe special_use = \Junk } mailbox Sent { - auto = create + auto = subscribe special_use = \Sent } mailbox Trash { - auto = create + auto = subscribe special_use = \Trash } From d1d53d52f9b3e8d6c3bd7246ae81e491e866ba6f Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 21 Mar 2014 09:18:00 +0100 Subject: [PATCH 03/18] IMG_ROOTDIR paramter removed. fix #530 --- modoboa/extensions/stats/app_settings.py | 6 ------ modoboa/extensions/stats/grapher.py | 3 ++- modoboa/lib/fixtures/demo.json | 8 -------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/modoboa/extensions/stats/app_settings.py b/modoboa/extensions/stats/app_settings.py index 8020e7299..86e0a749a 100644 --- a/modoboa/extensions/stats/app_settings.py +++ b/modoboa/extensions/stats/app_settings.py @@ -21,9 +21,3 @@ class ParametersForm(AdminParametersForm): initial="/tmp/modoboa", help_text=ugettext_lazy("Path to directory where RRD files are stored") ) - - img_rootdir = forms.CharField( - label=ugettext_lazy("Directory to store PNG files"), - initial="/tmp/modoboa", - help_text=ugettext_lazy("Path to directory where PNG files are stored") - ) diff --git a/modoboa/extensions/stats/grapher.py b/modoboa/extensions/stats/grapher.py index 289ae93d2..d410f74b1 100644 --- a/modoboa/extensions/stats/grapher.py +++ b/modoboa/extensions/stats/grapher.py @@ -3,6 +3,7 @@ import sys import os import time from django.utils.translation import ugettext as _, ugettext_lazy +from django.conf import settings from modoboa.lib import parameters periods = [{"name": "day", "label": ugettext_lazy("Day")}, @@ -35,7 +36,7 @@ def str2Time(y, M, d, h="00", m="00", s="00"): class Grapher(object): def __init__(self): self.rrd_rootdir = parameters.get_admin("RRD_ROOTDIR") - self.img_rootdir = parameters.get_admin("IMG_ROOTDIR") + self.img_rootdir = os.path.join(settings.MEDIA_ROOT, "stats") def process(self, target, suffix, start, end, graph_tpl): import rrdtool diff --git a/modoboa/lib/fixtures/demo.json b/modoboa/lib/fixtures/demo.json index cdd99ecb9..737dff0b0 100644 --- a/modoboa/lib/fixtures/demo.json +++ b/modoboa/lib/fixtures/demo.json @@ -39,14 +39,6 @@ "value": "vmail" } }, - { - "pk": 12, - "model": "lib.parameter", - "fields": { - "name": "stats.IMG_ROOTDIR", - "value": "/srv/www/modoboa/media/stats" - } - }, { "pk": 13, "model": "lib.parameter", From fe80cb288ebfd17db57bba56c59f63d35e60a404 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Wed, 26 Mar 2014 15:57:07 +0100 Subject: [PATCH 04/18] Fix attempt... see #534 --- .../amavis/management/commands/qcleanup.py | 2 +- modoboa/extensions/amavis/models.py | 2 +- modoboa/extensions/amavis/sql_listing.py | 38 ++++++++++++++++--- modoboa/extensions/amavis/views.py | 38 +++++++------------ 4 files changed, 49 insertions(+), 31 deletions(-) diff --git a/modoboa/extensions/amavis/management/commands/qcleanup.py b/modoboa/extensions/amavis/management/commands/qcleanup.py index c57aee2ef..c31d348f5 100644 --- a/modoboa/extensions/amavis/management/commands/qcleanup.py +++ b/modoboa/extensions/amavis/management/commands/qcleanup.py @@ -50,7 +50,7 @@ class Command(BaseCommand): flags += ['R'] self.__vprint("Deleting marked messages...") - ids = Msgrcpt.objects.filter(rs__in=flags).values("mail_id") + ids = Msgrcpt.objects.filter(rs__in=flags).values("mail_id").distinct() for msg in Msgs.objects.filter(mail_id__in=ids): if not msg.msgrcpt_set.exclude(rs__in=flags).count(): msg.delete() diff --git a/modoboa/extensions/amavis/models.py b/modoboa/extensions/amavis/models.py index 50f29305b..7a711fdd8 100644 --- a/modoboa/extensions/amavis/models.py +++ b/modoboa/extensions/amavis/models.py @@ -65,7 +65,7 @@ class Msgs(models.Model): class Msgrcpt(models.Model): partition_tag = models.IntegerField(null=True, blank=True) mail = models.ForeignKey(Msgs, primary_key=True) - rid = models.ForeignKey(Maddr, db_column='rid', primary_key=True) + rid = models.ForeignKey(Maddr, db_column='rid') rseqnum = models.IntegerField(default=0) is_local = models.CharField(max_length=3) content = models.CharField(max_length=3) diff --git a/modoboa/extensions/amavis/sql_listing.py b/modoboa/extensions/amavis/sql_listing.py index 71c2cd5aa..b7842eddb 100644 --- a/modoboa/extensions/amavis/sql_listing.py +++ b/modoboa/extensions/amavis/sql_listing.py @@ -9,7 +9,7 @@ from modoboa.lib.webutils import static_url from modoboa.lib.email_listing import MBconnector, EmailListing from modoboa.lib.emailutils import Email from modoboa.lib.dbutils import db_type -from modoboa.extensions.admin.models import Domain, Alias +from modoboa.extensions.admin.models import Domain from .models import Quarantine, Msgrcpt @@ -46,9 +46,10 @@ class Qtable(tables.Table): "date", label=ugettext_lazy("Date"), sort_order="date", cssclass="openable" ) - + cols_order = [ - 'selection', 'type', 'rstatus', 'score', 'to', 'from_', 'subject', 'time' + 'selection', 'type', 'rstatus', 'score', 'to', 'from_', 'subject', + 'time' ] def parse_type(self, value): @@ -101,7 +102,7 @@ class SQLconnector(MBconnector): order = self.order_translation_table[order] self.messages = self.messages.order_by(sign + order) - self.count = len(self.messages) + self.count = self.messages.count() return self.count def fetch(self, start=None, stop=None, **kwargs): @@ -115,7 +116,9 @@ class SQLconnector(MBconnector): "type": qm["mail__msgrcpt__content"], "score": qm["mail__msgrcpt__bspam_level"]} rs = qm["mail__msgrcpt__rs"] - if rs == '': + if rs == 'D': + continue + elif rs == '': m["class"] = "unseen" elif rs == 'R': m["img_rstatus"] = static_url("pics/release.png") @@ -138,6 +141,18 @@ class SQLWrapper(object): See ``PgWrapper`` for the real mess... """ + def _exec(self, query, args): + """Execute a raw SQL query. + + :param string query: query to execute + :param list args: a list of arguments to replace in :kw:`query` + """ + from django.db import connections, transaction + + cursor = connections['amavis'].cursor() + cursor.execute(query, args) + transaction.commit_unless_managed(using='amavis') + def get_mails(self, request, rcptfilter=None): """Retrieve all messages visible by a user. @@ -163,6 +178,19 @@ class SQLWrapper(object): def get_recipient_message(self, address, mailid): return Msgrcpt.objects.get(mail=mailid, rid__email=address) + def set_msgrcpt_status(self, address, mailid, status): + """Change the status (rs field) of a message recipient. + + :param string status: status + """ + from modoboa.extensions.amavis.models import Maddr + + addr = Maddr.objects.get(email=address) + self._exec( + "UPDATE msgrcpt SET rs=%s WHERE mail_id=%s AND rid=%s", + [status, mailid, addr.id] + ) + def get_recipient_messages(self, address, mailids): return Msgrcpt.objects.filter(mail__in=mailids, rid__email=address) diff --git a/modoboa/extensions/amavis/views.py b/modoboa/extensions/amavis/views.py index 91b3d52fb..c4d7f51d1 100644 --- a/modoboa/extensions/amavis/views.py +++ b/modoboa/extensions/amavis/views.py @@ -57,7 +57,6 @@ def _listing(request): else: raise BadRequest("unsupported search criteria %s" % c) flt = nfilter if flt is None else flt | nfilter - msgtype = navparams.get('msgtype', None) if msgtype is not None: nfilter = Q(mail__msgrcpt__content=msgtype) @@ -140,9 +139,7 @@ def viewmail(request, mail_id): if request.user.mailbox_set.count(): mb = Mailbox.objects.get(user=request.user) if rcpt in mb.alias_addresses: - msgrcpt = get_wrapper().get_recipient_message(rcpt, mail_id) - msgrcpt.rs = 'V' - msgrcpt.save() + get_wrapper().set_msgrcpt_status(rcpt, mail_id, 'V') content = Template(""" @@ -178,9 +175,7 @@ def delete_selfservice(request, mail_id): if rcpt is None: raise BadRequest(_("Invalid request")) try: - msgrcpt = get_wrapper().get_recipient_message(rcpt, mail_id) - msgrcpt.rs = 'D' - msgrcpt.save() + get_wrapper().set_msgrcpt_status(rcpt, mail_id, 'D') except Msgrcpt.DoesNotExist: raise BadRequest(_("Invalid request")) return render_to_json_response(_("Message deleted")) @@ -201,9 +196,7 @@ def delete(request, mail_id): if mb is not None and r != mb.full_address \ and not r in mb.alias_addresses: continue - msgrcpt = wrapper.get_recipient_message(r, i) - msgrcpt.rs = 'D' - msgrcpt.save() + wrapper.set_msgrcpt_status(r, i, 'D') message = ungettext("%(count)d message deleted successfully", "%(count)d messages deleted successfully", len(mail_id)) % {"count": len(mail_id)} @@ -218,24 +211,24 @@ def release_selfservice(request, mail_id): secret_id = request.GET.get("secret_id", None) if rcpt is None or secret_id is None: raise BadRequest(_("Invalid request")) + wrapper = get_wrapper() try: - msgrcpt = get_wrapper().get_recipient_message(rcpt, mail_id) + msgrcpt = wrapper.get_recipient_message(rcpt, mail_id) except Msgrcpt.DoesNotExist: raise BadRequest(_("Invalid request")) if secret_id != msgrcpt.mail.secret_id: raise BadRequest(_("Invalid request")) if parameters.get_admin("USER_CAN_RELEASE") == "no": - msgrcpt.rs = 'p' + wrapper.set_msgrcpt_status(rcpt, mail_id, 'p') msg = _("Request sent") else: amr = AMrelease() result = amr.sendreq(mail_id, secret_id, rcpt) if result: - rcpt.rs = 'R' + wrapper.set_msgrcpt_status(rcpt, mail_id, 'R') msg = _("Message released") else: raise BadRequest(result) - msgrcpt.save() return render_to_json_response(msg) @@ -257,14 +250,10 @@ def release(request, mail_id): continue msgrcpts += [wrapper.get_recipient_message(r, i)] if mb is not None and parameters.get_admin("USER_CAN_RELEASE") == "no": - # FIXME : can't use this syntax because extra SQL (using - # .extra() for postgres) is not propagated (the 'tables' - # parameter is lost somewhere...) - # - # msgrcpts.update(rs='p') for msgrcpt in msgrcpts: - msgrcpt.rs = 'p' - msgrcpt.save() + wrapper.set_msgrcpt_status( + msgrcpt.rid.email, msgrcpt.mail.mail_id, 'p' + ) message = ungettext("%(count)d request sent", "%(count)d requests sent", len(mail_id)) % {"count": len(mail_id)} @@ -276,10 +265,11 @@ def release(request, mail_id): amr = AMrelease() error = None for rcpt in msgrcpts: - result = amr.sendreq(rcpt.mail.mail_id, rcpt.mail.secret_id, rcpt.rid.email) + result = amr.sendreq( + rcpt.mail.mail_id, rcpt.mail.secret_id, rcpt.rid.email + ) if result: - rcpt.rs = 'R' - rcpt.save() + wrapper.set_msgrcpt_status(rcpt.rid.email, rcpt.mail.mail_id, 'R') else: error = result break From 056a70c412adf12853b8be80c65334f18c949b4f Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Wed, 2 Apr 2014 16:03:03 +0200 Subject: [PATCH 05/18] Bad import statement fixed. fix #538 --- modoboa/extensions/webmail/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modoboa/extensions/webmail/lib.py b/modoboa/extensions/webmail/lib.py index 0b099f8ef..5770086d0 100644 --- a/modoboa/extensions/webmail/lib.py +++ b/modoboa/extensions/webmail/lib.py @@ -3,7 +3,7 @@ import os import re from datetime import datetime, timedelta import email -import lxml +import lxml.html import chardet from rfc6266 import build_header, parse_headers from django.core.files.uploadhandler import FileUploadHandler, SkipFile From 4e4bd80074f818533deab534fad4f523d7bea2d0 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 7 Apr 2014 17:41:09 +0200 Subject: [PATCH 06/18] Additional checks on account import. fix #539 --- modoboa/extensions/admin/__init__.py | 14 ++++++++++--- modoboa/extensions/admin/tests/import_.py | 24 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/modoboa/extensions/admin/__init__.py b/modoboa/extensions/admin/__init__.py index a0f29e83b..12baf0961 100644 --- a/modoboa/extensions/admin/__init__.py +++ b/modoboa/extensions/admin/__init__.py @@ -153,7 +153,7 @@ def import_account_mailbox(user, account, row): :param list rom: list of fields (strings) """ account.email = row[0].strip() - if account.email != "": + if account.email: account.save() mailbox, domname = split_mailbox(account.email) try: @@ -169,8 +169,16 @@ def import_account_mailbox(user, account, row): except Mailbox.DoesNotExist: pass else: - raise Conflict(_("Mailbox %s already exists" % account.email)) - quota = int(row[1].strip()) + raise Conflict(_("Mailbox %s already exists" % account.email)) + if len(row) == 1: + quota = None + else: + try: + quota = int(row[1].strip()) + except ValueError: + raise BadRequest( + _("Account import failed (%s): wrong quota value" % account.username) + ) use_domain_quota = True if not quota else False mb = Mailbox(address=mailbox, domain=domain, user=account, use_domain_quota=use_domain_quota) diff --git a/modoboa/extensions/admin/tests/import_.py b/modoboa/extensions/admin/tests/import_.py index 8f02fed99..7d2c600d7 100644 --- a/modoboa/extensions/admin/tests/import_.py +++ b/modoboa/extensions/admin/tests/import_.py @@ -104,6 +104,16 @@ dlist; dlist@test.com; True; user1@test.com; user@extdomain.com self.assertTrue(admin.is_owner(dlist)) def test_import_invalid_quota(self): + f = ContentFile(b""" +account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com; ; test.com +""", name="identities.csv") + resp = self.clt.post( + reverse("modoboa.extensions.admin.views.import.import_identities"), + {"sourcefile": f, "crypt_password": True} + ) + self.assertIn('wrong quota value', resp.content) + + def test_import_quota_too_big(self): self.clt.logout() self.clt.login(username="admin@test.com", password="toto") f = ContentFile(b""" @@ -115,6 +125,20 @@ account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com; 20 ) self.assertIn('Quota is greater than the allowed', resp.content) + def test_import_missing_quota(self): + f = ContentFile(b""" +account; user1@test.com; toto; User; One; True; SimpleUsers; user1@test.com +""", name="identities.csv") + resp = self.clt.post( + reverse("modoboa.extensions.admin.views.import.import_identities"), + {"sourcefile": f, "crypt_password": True} + ) + account = User.objects.get(username="user1@test.com") + self.assertEqual( + account.mailbox_set.all()[0].quota, + account.mailbox_set.all()[0].domain.quota + ) + def test_import_duplicate(self): f = ContentFile(b""" account; admin@test.com; toto; Admin; ; True; DomainAdmins; admin@test.com; 0; test.com From a8406568b3b2d91f7a58beb88eea7ec426eccb6e Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 7 Apr 2014 18:23:16 +0200 Subject: [PATCH 07/18] Ignore empty recipients. fix #540 --- modoboa/extensions/admin/models/alias.py | 8 ++++++-- modoboa/extensions/admin/tests/import_.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/modoboa/extensions/admin/models/alias.py b/modoboa/extensions/admin/models/alias.py index 62c0d6f5b..64d587b9f 100644 --- a/modoboa/extensions/admin/models/alias.py +++ b/modoboa/extensions/admin/models/alias.py @@ -170,6 +170,8 @@ class Alias(AdminObject): ext_rcpts = [] for rcpt in row[3:]: rcpt = rcpt.strip() + if not rcpt: + continue localpart, domname = split_mailbox(rcpt) try: Domain.objects.get(name=domname) @@ -177,14 +179,16 @@ class Alias(AdminObject): ext_rcpts += [rcpt] continue try: - target = Alias.objects.get(domain__name=domname, address=localpart) + target = Alias.objects.get( + domain__name=domname, address=localpart + ) if target.full_address == self.full_address: target = None except Alias.DoesNotExist: target = None if target is None: try: - target = Mailbox.objects.get(address=localpart, + target = Mailbox.objects.get(address=localpart, domain__name=domname) except Mailbox.DoesNotExist: raise BadRequest(_("Local recipient %s not found" % rcpt)) diff --git a/modoboa/extensions/admin/tests/import_.py b/modoboa/extensions/admin/tests/import_.py index 7d2c600d7..8c1e6fc24 100644 --- a/modoboa/extensions/admin/tests/import_.py +++ b/modoboa/extensions/admin/tests/import_.py @@ -170,3 +170,17 @@ account; sa@test.com; toto; Super; Admin; True; SuperAdmins; superadmin@test.com ) with self.assertRaises(User.DoesNotExist): User.objects.get(username="sa@test.com") + + def test_import_alias_with_empty_values(self): + f = ContentFile(b""" +alias;user.alias@test.com;True;user@test.com;;;;;;;;;;;;;;;; +""", name="identities.csv") + self.clt.post( + reverse("modoboa.extensions.admin.views.import.import_identities"), + {"sourcefile": f, "crypt_password": True, + "continue_if_exists": True} + ) + alias = Alias.objects.get( + domain__name="test.com", address="user.alias" + ) + self.assertEqual(alias.type, "alias") From d3cd2462bbe540ce25a5389070c3fdc903678170 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Wed, 16 Apr 2014 10:39:28 +0200 Subject: [PATCH 08/18] Do nothing if the user is anonymous. fixes #548 --- modoboa/core/views/auth.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/modoboa/core/views/auth.py b/modoboa/core/views/auth.py index 67eb3f973..878b1cf81 100644 --- a/modoboa/core/views/auth.py +++ b/modoboa/core/views/auth.py @@ -61,8 +61,11 @@ dologin = never_cache(dologin) def dologout(request): - events.raiseEvent("UserLogout", request) - logger = logging.getLogger("modoboa.auth") - logger.info(_("User '%s' logged out" % request.user.username)) - logout(request) + """Logout the current user. + """ + if not request.user.is_anonymous(): + events.raiseEvent("UserLogout", request) + logger = logging.getLogger("modoboa.auth") + logger.info(_("User '%s' logged out" % request.user.username)) + logout(request) return HttpResponseRedirect(reverse(dologin)) From ef2263ad6a66a9a7f1d4fb46f94b21ac402f95cf Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 17 Apr 2014 20:18:10 +0200 Subject: [PATCH 09/18] CKeditor compatibility. see #547 --- modoboa/extensions/webmail/lib.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modoboa/extensions/webmail/lib.py b/modoboa/extensions/webmail/lib.py index 5770086d0..18f9c67d5 100644 --- a/modoboa/extensions/webmail/lib.py +++ b/modoboa/extensions/webmail/lib.py @@ -598,6 +598,8 @@ def html2plaintext(content): :param content: some HTML content """ + if not content: + return "" html = lxml.html.fromstring(content) plaintext = "" for ch in html.iter(): @@ -662,10 +664,14 @@ def send_mail(request, posturl=None): if editormode == "html": msg = MIMEMultipart(_subtype="related") submsg = MIMEMultipart(_subtype="alternative") - textbody = html2plaintext(body) + if body: + textbody = html2plaintext(body) + body, images = find_images_in_body(body) + else: + textbody = "" + images = [] submsg.attach(MIMEText(textbody.encode(charset), _subtype="plain", _charset=charset)) - body, images = find_images_in_body(body) submsg.attach(MIMEText(body.encode(charset), _subtype=editormode, _charset=charset)) msg.attach(submsg) From 923901c5a55b06d5e5946d13513f1542f2f91f52 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 24 Apr 2014 16:54:34 +0200 Subject: [PATCH 10/18] Don't try to use mbox if it is not defined. --- .../postfix_autoreply/management/commands/autoreply.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modoboa/extensions/postfix_autoreply/management/commands/autoreply.py b/modoboa/extensions/postfix_autoreply/management/commands/autoreply.py index 63fb9b655..3118f6cc9 100644 --- a/modoboa/extensions/postfix_autoreply/management/commands/autoreply.py +++ b/modoboa/extensions/postfix_autoreply/management/commands/autoreply.py @@ -56,7 +56,7 @@ class Command(BaseCommand): try: mbox = Mailbox.objects.get(address=address, domain__name=domain) except Mailbox.DoesNotExist: - print "Unknown recipient %s" % (mbox) + print "Unknown recipient %s" % (fulladdress) continue try: armessage = ARmessage.objects.get(mbox=mbox.id, enabled=True) From 3d1a1e586d8280b56317ef394cf2fdc4e013a571 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 1 May 2014 15:09:26 +0200 Subject: [PATCH 11/18] Forward a message AND its attachments. Code cleanup. see #545 --- modoboa/extensions/amavis/sql_listing.py | 34 +- modoboa/extensions/amavis/views.py | 14 +- modoboa/extensions/webmail/forms.py | 172 ++++ modoboa/extensions/webmail/lib.py | 773 ------------------ modoboa/extensions/webmail/lib/__init__.py | 35 + modoboa/extensions/webmail/lib/attachments.py | 112 +++ .../webmail/{ => lib}/fetch_parser.py | 0 modoboa/extensions/webmail/lib/imapemail.py | 307 +++++++ modoboa/extensions/webmail/lib/imapheader.py | 114 +++ .../extensions/webmail/{ => lib}/imaputils.py | 4 +- modoboa/extensions/webmail/lib/listing.py | 107 +++ modoboa/extensions/webmail/lib/sendmail.py | 65 ++ modoboa/extensions/webmail/lib/signature.py | 29 + modoboa/extensions/webmail/lib/tables.py | 48 ++ modoboa/extensions/webmail/lib/utils.py | 35 + .../webmail/static/webmail/js/webmail.js | 2 +- .../webmail/templates/webmail/compose.html | 4 +- .../webmail/templatetags/webmail_tags.py | 2 +- modoboa/extensions/webmail/views.py | 66 +- modoboa/lib/emailutils.py | 52 +- modoboa/lib/u2u_decode.py | 5 +- 21 files changed, 1139 insertions(+), 841 deletions(-) delete mode 100644 modoboa/extensions/webmail/lib.py create mode 100644 modoboa/extensions/webmail/lib/__init__.py create mode 100644 modoboa/extensions/webmail/lib/attachments.py rename modoboa/extensions/webmail/{ => lib}/fetch_parser.py (100%) create mode 100644 modoboa/extensions/webmail/lib/imapemail.py create mode 100644 modoboa/extensions/webmail/lib/imapheader.py rename modoboa/extensions/webmail/{ => lib}/imaputils.py (99%) create mode 100644 modoboa/extensions/webmail/lib/listing.py create mode 100644 modoboa/extensions/webmail/lib/sendmail.py create mode 100644 modoboa/extensions/webmail/lib/signature.py create mode 100644 modoboa/extensions/webmail/lib/tables.py create mode 100644 modoboa/extensions/webmail/lib/utils.py diff --git a/modoboa/extensions/amavis/sql_listing.py b/modoboa/extensions/amavis/sql_listing.py index b7842eddb..edc06fbb7 100644 --- a/modoboa/extensions/amavis/sql_listing.py +++ b/modoboa/extensions/amavis/sql_listing.py @@ -306,18 +306,16 @@ class SQLlisting(EmailListing): class SQLemail(Email): - def __init__(self, msg, *args, **kwargs): - super(SQLemail, self).__init__(msg, *args, **kwargs) + def __init__(self, *args, **kwargs): + super(SQLemail, self).__init__(*args, **kwargs) fields = ["From", "To", "Cc", "Date", "Subject"] for f in fields: label = f - self.headers += [{"name": label, "value": self.get_header(msg, f)}] - try: - label = re.sub("-", "_", label) - setattr(self, label, msg[f]) - except: - pass - qreason = self.get_header(msg, "X-Amavis-Alert") + self.headers += [ + {"name": label, "value": self.get_header(self.msg, f)} + ] + setattr(self, label, self.msg[f]) + qreason = self.get_header(self.msg, "X-Amavis-Alert") self.qtype = "" self.qreason = "" if qreason != "": @@ -328,11 +326,19 @@ class SQLemail(Email): self.qtype = "BAD HEADER SECTION" self.qreason = qreason[19:] - def get_header(self, msg, name): - for k in [name, name.upper()]: - if k in msg: - return msg[k] - return "" + @property + def msg(self): + """ + """ + import email + + if self._msg is None: + qmails = get_wrapper().get_mail_content(self.mailid) + self._msg = email.message_from_string( + "".join([qm.mail_text for qm in qmails]) + ) + self._parse(self._msg) + return self._msg def render_headers(self, **kwargs): return render_to_string("amavis/mailheaders.html", { diff --git a/modoboa/extensions/amavis/views.py b/modoboa/extensions/amavis/views.py index c4d7f51d1..d57b4af98 100644 --- a/modoboa/extensions/amavis/views.py +++ b/modoboa/extensions/amavis/views.py @@ -92,12 +92,7 @@ def index(request): def getmailcontent_selfservice(request, mail_id): - qmails = get_wrapper().get_mail_content(mail_id) - content = "" - for qm in qmails: - content += qm.mail_text - msg = email.message_from_string(content) - mail = SQLemail(msg, mformat="plain", links="0") + mail = SQLemail(mail_id, mformat="plain", links="0") return render(request, "common/viewmail.html", { "headers": mail.render_headers(), "mailbody": mail.body @@ -106,12 +101,7 @@ def getmailcontent_selfservice(request, mail_id): @selfservice(getmailcontent_selfservice) def getmailcontent(request, mail_id): - qmails = get_wrapper().get_mail_content(mail_id) - content = "" - for qm in qmails: - content += qm.mail_text - msg = email.message_from_string(content) - mail = SQLemail(msg, mformat="plain", links="0") + mail = SQLemail(mail_id, mformat="plain", links="0") return render(request, "common/viewmail.html", { "headers": mail.render_headers(), "mailbody": mail.body diff --git a/modoboa/extensions/webmail/forms.py b/modoboa/extensions/webmail/forms.py index aa6a0bb04..d543e2971 100644 --- a/modoboa/extensions/webmail/forms.py +++ b/modoboa/extensions/webmail/forms.py @@ -1,9 +1,84 @@ # coding: utf-8 +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import lxml.html + from django import forms +from django.conf import settings from django.utils.translation import ugettext_lazy as _ +from modoboa.lib import parameters +from modoboa.lib.emailutils import set_email_headers +from .lib import ( + ImapEmail, create_mail_attachment +) + + +def html2plaintext(content): + """HTML to plain text translation. + + :param content: some HTML content + """ + if not content: + return "" + html = lxml.html.fromstring(content) + plaintext = "" + for ch in html.iter(): + p = None + if ch.text is not None: + p = ch.text.strip('\r\t\n') + if ch.tag == "img": + p = ch.get("alt") + if p is None: + continue + plaintext += p + "\n" + return plaintext + + +def make_body_images_inline(body): + """Looks for images inside the body and make them inline. + + Before sending a message in HTML format, it is necessary to find + all img tags contained in the body in order to rewrite them. For + example, icons provided by CKeditor are stored on the server + filesystem and not accessible from the outside. We must embark + them as parts of the MIME message if we want recipients to + display them correctly. + + :param body: the HTML body to parse + """ + import os + from email.mime.image import MIMEImage + from urlparse import urlparse + + html = lxml.html.fromstring(body) + parts = [] + for tag in html.iter("img"): + src = tag.get("src") + if src is None: + continue + o = urlparse(src) + path = os.path.join(settings.MODOBOA_DIR, o.path[1:]) + if not os.path.exists(path): + continue + fname = os.path.basename(path) + cid = "%s@modoboa" % os.path.splitext(fname)[0] + tag.set("src", "cid:%s" % cid) + with open(path, "rb") as fp: + part = MIMEImage(fp.read()) + part["Content-ID"] = "<%s>" % cid + part.replace_header( + "Content-Type", '%s; name="%s"' % (part["Content-Type"], fname) + ) + part["Content-Disposition"] = "inline" + parts.append(part) + return lxml.html.tostring(html), parts + class ComposeMailForm(forms.Form): + """Compose mail form. + """ to = forms.CharField(label=_("To")) cc = forms.CharField(label=_("Cc"), required=False) cci = forms.CharField(label=_("Cci"), required=False) @@ -13,6 +88,103 @@ class ComposeMailForm(forms.Form): origmsgid = forms.CharField( label="", widget=forms.HiddenInput(), required=False ) + body = forms.CharField(widget=forms.widgets.Textarea) + + def _html_msg(self): + """Create a multipart message. + + We attach two alternatives: + * text/html + * text/plain + """ + msg = MIMEMultipart(_subtype="related") + submsg = MIMEMultipart(_subtype="alternative") + body = self.cleaned_data["body"] + charset = "utf-8" + if body: + tbody = html2plaintext(body) + body, images = make_body_images_inline(body) + else: + tbody = "" + images = [] + submsg.attach( + MIMEText(tbody.encode(charset), _subtype="plain", _charset=charset) + ) + submsg.attach( + MIMEText(body.encode(charset), _subtype="html", _charset=charset) + ) + msg.attach(submsg) + for img in images: + msg.attach(img) + return msg + + def _plain_msg(self): + """Create a simple text message. + """ + charset = "utf-8" + text = MIMEText(self.cleaned_data["body"].encode(charset), + _subtype="plain", _charset=charset) + return text + + def _build_msg(self, request): + """Convert form's content to a MIME message. + """ + editormode = parameters.get_user(request.user, "EDITOR") + msg = getattr(self, "_%s_msg" % editormode)() + + if request.session["compose_mail"]["attachments"]: + wrapper = MIMEMultipart(_subtype="mixed") + wrapper.attach(msg) + for attdef in request.session["compose_mail"]["attachments"]: + wrapper.attach(create_mail_attachment(attdef)) + msg = wrapper + return msg + + def to_msg(self, request): + """Convert form's content to an object ready to send. + + We set headers at the end to be sure no one will override + them. + + """ + msg = self._build_msg(request) + set_email_headers( + msg, self.cleaned_data["subject"], request.user.encoded_address, + self.cleaned_data['to'] + ) + origmsgid = self.cleaned_data.get("origmsgid", None) + if origmsgid: + msg["References"] = msg["In-Reply-To"] = origmsgid + return msg + + +class ForwardMailForm(ComposeMailForm): + """Forward mail form. + """ + + def _build_msg(self, request): + """Convert form's content to a MIME message. + + We also add original attachments (if any) to the new message. + """ + from modoboa.extensions.webmail.lib import decode_payload + + mbox = request.GET.get("mbox", None) + mailid = request.GET.get("mailid", None) + msg = super(ForwardMailForm, self)._build_msg(request) + origmsg = ImapEmail(request, False, "%s:%s" % (mbox, mailid)) + if origmsg.attachments: + if not msg.is_multipart or not msg.get_content_subtype() == "mixed": + wrapper = MIMEMultipart(_subtype="mixed") + wrapper.attach(msg) + msg = wrapper + for attpart, fname in origmsg.attachments.items(): + attdef, payload = origmsg.fetch_attachment(attpart) + attdef["fname"] = fname + msg.attach(create_mail_attachment( + attdef, decode_payload(attdef["encoding"], payload) + )) + return msg class FolderForm(forms.Form): diff --git a/modoboa/extensions/webmail/lib.py b/modoboa/extensions/webmail/lib.py deleted file mode 100644 index 18f9c67d5..000000000 --- a/modoboa/extensions/webmail/lib.py +++ /dev/null @@ -1,773 +0,0 @@ -# coding: utf-8 -import os -import re -from datetime import datetime, timedelta -import email -import lxml.html -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 -from modoboa.lib import u2u_decode, tables, parameters -from modoboa.lib.webutils import size2integer, NavigationParameters -from modoboa.lib.exceptions import InternalError -from modoboa.lib.email_listing import EmailListing -from modoboa.lib.emailutils import ( - EmailAddress, Email, prepare_addresses, set_email_headers -) -from modoboa.extensions.webmail.exceptions import WebmailInternalError -from modoboa.extensions.webmail.imaputils import ( - IMAPconnector, get_imapconnector, BodyStructure -) - - -class WMtable(tables.Table): - tableid = "emails" - styles = "table-condensed" - idkey = "imapid" - drag = tables.ImgColumn( - "drag", cssclass="draggable left", width="1%", - defvalue="%spics/grippy.png" % settings.STATIC_URL, - header="" - ) - selection = tables.SelectionColumn( - "selection", safe=True, width='1%', header=None, sortable=False - ) - flags = tables.ImgColumn("flags", width="3%") - withatts = tables.ImgColumn("withatts", width="2%") - subject = tables.Column( - "subject", label=ugettext_lazy("Subject"), width="50%", limit=60, - cssclass="openable" - ) - from_ = tables.Column( - "from", width="20%", label=ugettext_lazy("From"), limit=30, - cssclass="openable" - ) - date = tables.Column( - "date", width="15%", label=ugettext_lazy("Date"), cssclass="openable" - ) - - cols_order = ["drag", "selection", "withatts", "flags", "subject", "from_", "date"] - - def parse(self, header, value): - if value is None: - return "" - try: - value = getattr(IMAPheader, "parse_%s" % header)(value) - except AttributeError: - pass - return value - - -class IMAPheader(object): - @staticmethod - def to_unicode(value): - if value is None or type(value) is unicode: - return value - try: - value = value.decode('utf-8') - except UnicodeDecodeError: - pass - else: - return value - try: - res = chardet.detect(value) - except UnicodeDecodeError: - return value - if res["encoding"] == "ascii": - return value - return value.decode(res["encoding"]) - - @staticmethod - def parse_address(value, **kwargs): - addr = EmailAddress(value) - if "full" in kwargs.keys() and kwargs["full"]: - return IMAPheader.to_unicode(addr.fulladdress) - result = addr.name and addr.name or addr.fulladdress - return IMAPheader.to_unicode(result) - - @staticmethod - def parse_address_list(values, **kwargs): - lst = values.split(",") - result = "" - for addr in lst: - if result != "": - result += ", " - result += IMAPheader.parse_address(addr, **kwargs) - return result - - @staticmethod - def parse_from(value, **kwargs): - return IMAPheader.parse_address(value, **kwargs) - - @staticmethod - def parse_to(value, **kwargs): - return IMAPheader.parse_address_list(value, **kwargs) - - @staticmethod - def parse_cc(value, **kwargs): - return IMAPheader.parse_address_list(value, **kwargs) - - @staticmethod - def parse_reply_to(value, **kwargs): - return IMAPheader.parse_address_list(value, **kwargs) - - @staticmethod - def parse_date(value, **kwargs): - tmp = email.utils.parsedate_tz(value) - if not tmp: - return value - ndate = datetime(*(tmp)[:7]) - now = datetime.now() - try: - if now - ndate > timedelta(7): - return ndate.strftime("%d.%m.%Y %H:%M") - return ndate.strftime("%a %H:%M") - except ValueError: - return value - - @staticmethod - def parse_message_id(value, **kwargs): - return value.strip('\n') - - @staticmethod - def parse_subject(value, **kwargs): - try: - subject = u2u_decode.u2u_decode(value) - except UnicodeDecodeError: - subject = value - return IMAPheader.to_unicode(subject) - - -class ImapListing(EmailListing): - tpl = "webmail/index.html" - tbltype = WMtable - deflocation = "INBOX/" - defcallback = "wm_updatelisting" - reset_wm_url = False - - def __init__(self, user, password, **kwargs): - self.user = user - self.mbc = IMAPconnector(user=user.username, password=password) - if "pattern" in kwargs and kwargs["pattern"]: - self.parse_search_parameters( - kwargs["criteria"], kwargs["pattern"] - ) - else: - self.mbc.criterions = [] - - super(ImapListing, self).__init__(**kwargs) - self.extravars["refreshrate"] = \ - int(parameters.get_user(user, "REFRESH_INTERVAL")) * 1000 - - def parse_search_parameters(self, criterion, pattern): - - def or_criterion(old, c): - if old == "": - return c - return "OR (%s) (%s)" % (old, c) - - if criterion == u"both": - criterion = u"from_addr, subject" - criterions = "" - if type(pattern) is unicode: - pattern = pattern.encode("utf-8") - if type(criterion) is unicode: - criterion = criterion.encode("utf-8") - for c in criterion.split(','): - if c == "from_addr": - key = "FROM" - elif c == "subject": - key = "SUBJECT" - else: - continue - criterions = \ - or_criterion(criterions, '(%s "%s")' % (key, pattern)) - self.mbc.criterions = [criterions] - - @staticmethod - def computequota(mbc): - try: - return int(float(mbc.quota_actual) \ - / float(mbc.quota_limit) * 100) - except (AttributeError, TypeError): - return -1 - - def getquota(self): - return ImapListing.computequota(self.mbc) - - -class ImapEmail(Email): - headernames = [ - ('From', True), - ('To', True), - ('Cc', True), - ('Date', True), - ('Subject', True), - ] - - def __init__(self, mbox, mailid, request, dformat="DISPLAYMODE", addrfull=False, - links=0): - mformat = parameters.get_user(request.user, "DISPLAYMODE") - self.dformat = parameters.get_user(request.user, dformat) - - self.headers = [] - self.attachments = {} - self.imapc = get_imapconnector(request) - msg = self.imapc.fetchmail(mbox, mailid, readonly=False, - headers=self.headers_as_list) - self.mbox = mbox - self.mailid = mailid - headers = msg['BODY[HEADER.FIELDS (%s)]' % self.headers_as_text] - fallback_fmt = "html" if self.dformat == "plain" else "plain" - - self.bs = BodyStructure(msg['BODYSTRUCTURE']) - data = None - - mformat = self.dformat if self.dformat in self.bs.contents else fallback_fmt - - if len(self.bs.contents): - bodyc = u'' - for part in self.bs.contents[mformat]: - pnum = part['pnum'] - data = self.imapc._cmd("FETCH", mailid, "(BODY.PEEK[%s])" % pnum) - content = decode_payload(part['encoding'], - data[int(mailid)]['BODY[%s]' % pnum]) - charset = self._find_content_charset(part) - if charset is not None: - try: - content = content.decode(charset) - except (UnicodeDecodeError, LookupError): - result = chardet.detect(content) - content = content.decode(result['encoding']) - bodyc += content - - self._fetch_inlines() - self.body = \ - getattr(self, "viewmail_%s" % mformat)(bodyc, links=links) - else: - self.body = None - - self._find_attachments() - - msg = email.message_from_string(headers) - for hdr in self.headernames: - label = hdr[0] - name = hdr[0] - if not name in msg.keys(): - name = name.upper() - if not name in msg.keys(): - continue - try: - key = re.sub("-", "_", name).lower() - value = getattr(IMAPheader, "parse_%s" % key)(msg[name], full=addrfull) - except AttributeError: - value = msg[name] - if hdr[1]: - self.headers += [{"name": label, "value": value}] - try: - label = re.sub("-", "_", label) - setattr(self, label, value) - except: - pass - - @property - def headers_as_list(self): - return [hdr[0].upper() for hdr in self.headernames] - - @property - def headers_as_text(self): - return " ".join(self.headers_as_list) - - def _find_content_charset(self, part): - for pos, elem in enumerate(part["params"]): - if elem == "charset": - return part["params"][pos + 1] - 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"] - if "params" in att and att["params"] != "NIL": - 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): - for cid, params in self.bs.inlines.iteritems(): - if re.search("\.\.", cid): - continue - fname = "webmail/%s_%s" % (self.mailid, cid) - path = os.path.join(settings.MEDIA_ROOT, fname) - params["fname"] = os.path.join(settings.MEDIA_URL, fname) - if os.path.exists(path): - continue - - pdef, content = self.imapc.fetchpart(self.mailid, self.mbox, params["pnum"]) - fp = open(path, "wb") - fp.write(decode_payload(params["encoding"], content)) - fp.close() - - def map_cid(self, url): - m = re.match(".*cid:(.+)", url) - if m: - if m.group(1) in self.bs.inlines: - return self.bs.inlines[m.group(1)]["fname"] - return url - - def render_headers(self, **kwargs): - from django.template.loader import render_to_string - - res = render_to_string("webmail/headers.html", { - "headers": self.headers, - "folder": kwargs["folder"], "mail_id": kwargs["mail_id"], - "attachments": self.attachments != {} and self.attachments or None - }) - return res - - -class Modifier(ImapEmail): - def __init__(self, *args, **kwargs): - super(Modifier, self).__init__(*args, **kwargs) - getattr(self, "_modify_%s" % self.dformat)() - - def _modify_plain(self): - self.body = re.sub("", "", self.body) - - def _modify_html(self): - pass - - -class ReplyModifier(Modifier): - headernames = ImapEmail.headernames + \ - [("Reply-To", True), - ("Message-ID", False)] - - def __init__(self, mbox, mailid, request, form, **kwargs): - super(ReplyModifier, self).__init__( - mbox, mailid, request, dformat="EDITOR", **kwargs - ) - - self.textheader = "%s %s" % (self.From, _("wrote:")) - - if hasattr(self, "Message_ID"): - form.fields["origmsgid"].initial = self.Message_ID - if not hasattr(self, "Reply_To"): - form.fields["to"].initial = self.From - else: - form.fields["to"].initial = self.Reply_To - if request.GET.get("all", "0") == "1": # reply-all - form.fields["cc"].initial = "" - toparse = self.To.split(",") - if hasattr(self, 'Cc'): - toparse += self.Cc.split(",") - for addr in toparse: - tmp = EmailAddress(addr) - if tmp.address and tmp.address == request.user.username: - continue - if form.fields["cc"].initial != "": - form.fields["cc"].initial += ", " - form.fields["cc"].initial += tmp.fulladdress - m = re.match("re\s*:\s*.+", self.Subject.lower()) - if m: - form.fields["subject"].initial = self.Subject - else: - form.fields["subject"].initial = "Re: %s" % self.Subject - - def _modify_plain(self): - super(ReplyModifier, self)._modify_plain() - lines = self.body.split('\n') - body = "" - for l in lines: - if body != "": - body += "\n" - body += ">%s" % l - self.body = body - - -class ForwardModifier(Modifier): - def __init__(self, mbox, mailid, request, form, **kwargs): - super(ForwardModifier, self).__init__( - mbox, mailid, request, dformat="EDITOR", **kwargs - ) - - self._header() - form.fields["subject"].initial = "Fwd: %s" % self.Subject - - def __getfunc(self, name): - return getattr(self, "%s_%s" % (name, self.dformat)) - - def _header(self): - self.textheader = self.__getfunc("_header_begin")() + "\n" - self.textheader += \ - self.__getfunc("_header_line")(_("Subject"), self.Subject) + "\n" - self.textheader += \ - self.__getfunc("_header_line")(_("Date"), self.Date) + "\n" - for hdr in ["From", "To", "Reply-To"]: - try: - key = re.sub("-", "_", hdr) - value = getattr(self, key) - self.textheader += \ - self.__getfunc("_header_line")(_(hdr), value) + "\n" - except: - pass - self.textheader += self.__getfunc("_header_end")() - - def _header_begin_plain(self): - return "----- %s -----" % _("Original message") - - def _header_begin_html(self): - return """----- %s -----
-""" % _("Original message") - - def _header_line_plain(self, key, value): - return "%s: %s" % (key, value) - - def _header_line_html(self, key, value): - return " " % (key, value) - - def _header_end_plain(self): - return "\n" - - def _header_end_html(self): - return "
%s%s
" - - -class EmailSignature(object): - """User signature - - :param user: User object - """ - def __init__(self, user): - self._sig = "" - dformat = parameters.get_user(user, "EDITOR") - content = parameters.get_user(user, "SIGNATURE") - if len(content): - getattr(self, "_format_sig_%s" % dformat)(content) - - def _format_sig_plain(self, content): - self._sig = """ ---- -%s""" % content - - def _format_sig_html(self, content): - content = re.sub("\n", "
", content) - self._sig = """
----
-%s""" % content - - def __repr__(self): - return self._sig - - -def decode_payload(encoding, payload): - """Decode the payload according to the given encoding - - Supported encodings: base64, quoted-printable. - - :param encoding: the encoding's name - :param payload: the value to decode - :return: a string - """ - encoding = encoding.lower() - if encoding == "base64": - import base64 - return base64.b64decode(payload) - elif encoding == "quoted-printable": - import quopri - return quopri.decodestring(payload) - return payload - - -def find_images_in_body(body): - """Looks for images inside a HTML body - - Before sending a message in HTML format, it is necessary to find - all img tags contained in the body in order to rewrite them. For - example, icons provided by CKeditor are stored on the server - filesystem and not accessible from the outside. We must embark - them as parts off the MIME message if we want recipients to - display them correctly. - - :param body: the HTML body to parse - """ - from email.mime.image import MIMEImage - from urlparse import urlparse - - html = lxml.html.fromstring(body) - parts = [] - for tag in html.iter("img"): - src = tag.get("src") - if src is None: - continue - o = urlparse(src) - fname = os.path.basename(o.path) - path = os.path.join(settings.MEDIA_ROOT, "webmail", fname) - if not os.path.exists(path): - continue - cid = "%s@modoboa" % os.path.splitext(fname)[0] - tag.set("src", "cid:%s" % cid) - fp = open(path, "rb") - p = MIMEImage(fp.read()) - fp.close() - p["Content-ID"] = "<%s>" % cid - ct = p["Content-Type"] - p.replace_header("Content-Type", '%s; name="%s"' - % (ct, os.path.basename(fname))) - p["Content-Disposition"] = "inline" - parts.append(p) - - return lxml.html.tostring(html), parts - - -def set_compose_session(request): - """Initialize a new "compose" session. - - It is used to keep track of attachments defined with a new - message. Each new message will be associated with a unique ID (in - order to avoid conflicts between users). - - :param request: a Request object. - :return: the new unique ID. - """ - import uuid - randid = str(uuid.uuid4()).replace("-", "") - request.session["compose_mail"] = {"id": randid, "attachments": []} - return randid - - -def save_attachment(f): - """Save a new attachment to the filesystem. - - The attachment is not saved using its own name to the - filesystem. To avoid conflicts, a random name is generated and - used instead. - - :param f: an uploaded file object (see Django's documentation) - :return: the new random name - """ - from tempfile import NamedTemporaryFile - - dstdir = os.path.join(settings.MEDIA_ROOT, "webmail") - try: - fp = NamedTemporaryFile(dir=dstdir, delete=False) - except Exception as e: - raise InternalError(str(e)) - for chunk in f.chunks(): - fp.write(chunk) - fp.close() - return fp.name - - -def clean_attachments(attlist): - """Remove all attachments from the filesystem - - :param attlist: a list of 2-uple. Each element must contain the - following information : (random name, real name). - """ - for att in attlist: - fullpath = os.path.join(settings.MEDIA_ROOT, "webmail", att["tmpname"]) - try: - os.remove(fullpath) - except OSError: - pass - - -def html2plaintext(content): - """HTML to plain text translation - - :param content: some HTML content - """ - if not content: - return "" - html = lxml.html.fromstring(content) - plaintext = "" - for ch in html.iter(): - p = None - if ch.text is not None: - p = ch.text.strip('\r\t\n') - if ch.tag == "img": - p = ch.get("alt") - if p is None: - continue - plaintext += p + "\n" - - return plaintext - - -def create_mail_attachment(attdef): - """Create the MIME part corresponding to the given attachment. - - Mandatory keys: 'fname', 'tmpname', 'content-type' - - :param attdef: a dictionary containing the attachment definition - :return: a MIMEBase object - """ - from email import Encoders - from email.mime.base import MIMEBase - - maintype, subtype = attdef["content-type"].split("/") - res = MIMEBase(maintype, subtype) - fp = open(os.path.join(settings.MEDIA_ROOT, "webmail", attdef["tmpname"]), "rb") - res.set_payload(fp.read()) - fp.close() - Encoders.encode_base64(res) - res['Content-Disposition'] = build_header(attdef['fname'].decode('utf-8')) - return res - - -def send_mail(request, posturl=None): - """Email verification and sending. - - If the form does not present any error, a new MIME message is - constructed. Then, a connection is established with the defined - SMTP server and the message is finally sent. - - :param request: a Request object - :param posturl: the url to post the message form to - :return: a 2-uple (True|False, HttpResponse) - """ - from email.mime.multipart import MIMEMultipart - from .forms import ComposeMailForm - from modoboa.lib.webutils import _render_to_string - from modoboa.lib.cryptutils import get_password - - form = ComposeMailForm(request.POST) - editormode = parameters.get_user(request.user, "EDITOR") - if form.is_valid(): - from email.mime.text import MIMEText - import smtplib - - body = request.POST["id_body"] - charset = "utf-8" - - if editormode == "html": - msg = MIMEMultipart(_subtype="related") - submsg = MIMEMultipart(_subtype="alternative") - if body: - textbody = html2plaintext(body) - body, images = find_images_in_body(body) - else: - textbody = "" - images = [] - submsg.attach(MIMEText(textbody.encode(charset), - _subtype="plain", _charset=charset)) - submsg.attach(MIMEText(body.encode(charset), _subtype=editormode, - _charset=charset)) - msg.attach(submsg) - for img in images: - msg.attach(img) - else: - text = MIMEText(body.encode(charset), - _subtype=editormode, _charset=charset) - if len(request.session["compose_mail"]["attachments"]): - msg = MIMEMultipart() - msg.attach(text) - else: - msg = text - - for attdef in request.session["compose_mail"]["attachments"]: - msg.attach(create_mail_attachment(attdef)) - - set_email_headers( - msg, form.cleaned_data["subject"], request.user.encoded_address, - form.cleaned_data['to'] - ) - if "origmsgid" in form.cleaned_data: - msg["References"] = msg["In-Reply-To"] = form.cleaned_data["origmsgid"] - rcpts = prepare_addresses(form.cleaned_data["to"], "envelope") - for hdr in ["cc", "cci"]: - if form.cleaned_data[hdr] != "": - msg[hdr.capitalize()] = prepare_addresses(form.cleaned_data[hdr]) - rcpts += prepare_addresses(form.cleaned_data[hdr], "envelope") - try: - secmode = parameters.get_admin("SMTP_SECURED_MODE") - if secmode == "ssl": - s = smtplib.SMTP_SSL(parameters.get_admin("SMTP_SERVER"), - int(parameters.get_admin("SMTP_PORT"))) - else: - s = smtplib.SMTP(parameters.get_admin("SMTP_SERVER"), - int(parameters.get_admin("SMTP_PORT"))) - if secmode == "starttls": - s.starttls() - except Exception as text: - raise WebmailInternalError(str(text)) - - if parameters.get_admin("SMTP_AUTHENTICATION") == "yes": - try: - s.login(request.user.username, get_password(request)) - except smtplib.SMTPException, e: - raise WebmailInternalError(str(e)) - try: - s.sendmail(request.user.email, rcpts, msg.as_string()) - s.quit() - except smtplib.SMTPException, e: - raise WebmailInternalError(str(e)) - - sentfolder = parameters.get_user(request.user, "SENT_FOLDER") - IMAPconnector(user=request.user.username, - password=request.session["password"]).push_mail(sentfolder, msg) - clean_attachments(request.session["compose_mail"]["attachments"]) - del request.session["compose_mail"] - return True, {} - - listing = _render_to_string(request, "webmail/compose.html", - {"form": form, "noerrors": True, - "body": request.POST["id_body"].strip(), - "posturl": posturl}) - return False, dict(status="ko", listing=listing, editor=editormode) - - -class AttachmentUploadHandler(FileUploadHandler): - """ - Simple upload handler to limit the size of the attachments users - can upload. - """ - - def __init__(self, request=None): - super(AttachmentUploadHandler, self).__init__(request) - self.total_upload = 0 - self.toobig = False - self.maxsize = size2integer(parameters.get_admin("MAX_ATTACHMENT_SIZE")) - - def receive_data_chunk(self, raw_data, start): - self.total_upload += len(raw_data) - if self.total_upload >= self.maxsize: - self.toobig = True - raise SkipFile() - return raw_data - - def file_complete(self, file_size): - return None - - -class WebmailNavigationParameters(NavigationParameters): - """ - Specific NavigationParameters subclass for the webmail. - """ - def __init__(self, request, defmailbox=None): - super(WebmailNavigationParameters, self).__init__( - request, 'webmail_navparams' - ) - if defmailbox is not None: - self.parameters += [('mbox', defmailbox, False)] diff --git a/modoboa/extensions/webmail/lib/__init__.py b/modoboa/extensions/webmail/lib/__init__.py new file mode 100644 index 000000000..bf1f4b89f --- /dev/null +++ b/modoboa/extensions/webmail/lib/__init__.py @@ -0,0 +1,35 @@ +from .attachments import ( + create_mail_attachment, save_attachment, clean_attachments, + set_compose_session, AttachmentUploadHandler +) +from .imapemail import ImapEmail, ReplyModifier, ForwardModifier +from .imaputils import ( + BodyStructure, IMAPconnector, get_imapconnector, separate_mailbox +) +from .listing import ImapListing +from .signature import EmailSignature +from .utils import decode_payload, WebmailNavigationParameters +from .sendmail import send_mail +from .fetch_parser import parse_fetch_response + + +__all__ = [ + 'AttachmentUploadHandler', + 'BodyStructure', + 'EmailSignature', + 'ForwardModifier', + 'IMAPconnector', + 'ImapEmail', + 'ImapListing', + 'ReplyModifier', + 'WebmailNavigationParameters', + 'clean_attachments', + 'create_mail_attachment', + 'decode_payload', + 'get_imapconnector', + 'parse_fetch_response', + 'save_attachment', + 'send_mail', + 'separate_mailbox', + 'set_compose_session', +] diff --git a/modoboa/extensions/webmail/lib/attachments.py b/modoboa/extensions/webmail/lib/attachments.py new file mode 100644 index 000000000..b891df0a9 --- /dev/null +++ b/modoboa/extensions/webmail/lib/attachments.py @@ -0,0 +1,112 @@ +import os +from rfc6266 import build_header +from django.conf import settings +from django.core.files.uploadhandler import FileUploadHandler, SkipFile +from modoboa.lib import parameters +from modoboa.lib.exceptions import InternalError +from modoboa.lib.webutils import size2integer + + +def set_compose_session(request): + """Initialize a new "compose" session. + + It is used to keep track of attachments defined with a new + message. Each new message will be associated with a unique ID (in + order to avoid conflicts between users). + + :param request: a Request object. + :return: the new unique ID. + """ + import uuid + randid = str(uuid.uuid4()).replace("-", "") + request.session["compose_mail"] = {"id": randid, "attachments": []} + return randid + + +def save_attachment(f): + """Save a new attachment to the filesystem. + + The attachment is not saved using its own name to the + filesystem. To avoid conflicts, a random name is generated and + used instead. + + :param f: an uploaded file object (see Django's documentation) + :return: the new random name + """ + from tempfile import NamedTemporaryFile + + dstdir = os.path.join(settings.MEDIA_ROOT, "webmail") + try: + fp = NamedTemporaryFile(dir=dstdir, delete=False) + except Exception as e: + raise InternalError(str(e)) + for chunk in f.chunks(): + fp.write(chunk) + fp.close() + return fp.name + + +def clean_attachments(attlist): + """Remove all attachments from the filesystem + + :param attlist: a list of 2-uple. Each element must contain the + following information : (random name, real name). + """ + for att in attlist: + fullpath = os.path.join(settings.MEDIA_ROOT, "webmail", att["tmpname"]) + try: + os.remove(fullpath) + except OSError: + pass + + +def create_mail_attachment(attdef, payload=None): + """Create the MIME part corresponding to the given attachment. + + Mandatory keys: 'fname', 'tmpname', 'content-type' + + :param attdef: a dictionary containing the attachment definition + :return: a MIMEBase object + """ + from email import Encoders + from email.mime.base import MIMEBase + + if "content-type" in attdef: + maintype, subtype = attdef["content-type"].split("/") + elif "Content-Type" in attdef: + maintype, subtype = attdef["Content-Type"].split("/") + else: + return None + res = MIMEBase(maintype, subtype) + if payload is None: + with open(os.path.join( + settings.MEDIA_ROOT, "webmail", attdef["tmpname"]), "rb") as fp: + res.set_payload(fp.read()) + else: + res.set_payload(payload) + Encoders.encode_base64(res) + res['Content-Disposition'] = build_header(attdef['fname'].decode('utf-8')) + return res + + +class AttachmentUploadHandler(FileUploadHandler): + """ + Simple upload handler to limit the size of the attachments users + can upload. + """ + + def __init__(self, request=None): + super(AttachmentUploadHandler, self).__init__(request) + self.total_upload = 0 + self.toobig = False + self.maxsize = size2integer(parameters.get_admin("MAX_ATTACHMENT_SIZE")) + + def receive_data_chunk(self, raw_data, start): + self.total_upload += len(raw_data) + if self.total_upload >= self.maxsize: + self.toobig = True + raise SkipFile() + return raw_data + + def file_complete(self, file_size): + return None diff --git a/modoboa/extensions/webmail/fetch_parser.py b/modoboa/extensions/webmail/lib/fetch_parser.py similarity index 100% rename from modoboa/extensions/webmail/fetch_parser.py rename to modoboa/extensions/webmail/lib/fetch_parser.py diff --git a/modoboa/extensions/webmail/lib/imapemail.py b/modoboa/extensions/webmail/lib/imapemail.py new file mode 100644 index 000000000..e07f92f71 --- /dev/null +++ b/modoboa/extensions/webmail/lib/imapemail.py @@ -0,0 +1,307 @@ +""" +Set of classes to manipulate/display emails inside the webmail. +""" +import os +import re +import email + +import chardet +from rfc6266 import parse_headers + +from django.conf import settings +from django.utils.translation import ugettext as _ + +from modoboa.lib import parameters, u2u_decode +from modoboa.lib.emailutils import Email, EmailAddress +from .imaputils import ( + get_imapconnector, BodyStructure +) +from .utils import decode_payload + + +class ImapEmail(Email): + """ + A class to represent an email fetched from an IMAP server. + """ + headernames = [ + ('From', True), + ('To', True), + ('Cc', True), + ('Date', True), + ('Subject', True), + ] + + def __init__(self, request, addrfull, *args, **kwargs): + super(ImapEmail, self).__init__(*args, **kwargs) + self.request = request + self.addrfull = addrfull + self.imapc = get_imapconnector(request) + self.mbox, self.mailid = self.mailid.split(":") + + headers = self.msg['BODY[HEADER.FIELDS (%s)]' % self.headers_as_text] + msg = email.message_from_string(headers) + for hdr in self.headernames: + label = hdr[0] + hdrvalue = self.get_header(msg, label) + if not hdrvalue: + continue + if hdr[1]: + self.headers += [{"name": label, "value": hdrvalue}] + label = re.sub("-", "_", label) + setattr(self, label, hdrvalue) + + def get_header(self, msg, hdrname): + """Look for a particular header. + + We also try to decode the default value. + """ + from . import imapheader + + hdrvalue = super(ImapEmail, self).get_header(msg, hdrname) + if not hdrvalue: + return "" + try: + key = re.sub("-", "_", hdrname).lower() + hdrvalue = getattr(imapheader, "parse_%s" % key)( + hdrvalue, full=self.addrfull + ) + except AttributeError: + pass + return hdrvalue + + @property + def msg(self): + """ + """ + if self._msg is None: + self._msg = self.imapc.fetchmail( + self.mbox, self.mailid, readonly=False, + headers=self.headers_as_list + ) + self.bs = BodyStructure(self._msg['BODYSTRUCTURE']) + self._find_attachments() + if not self.dformat in ["plain", "html"]: + self.dformat = parameters.get_user( + self.request.user, self.dformat + ) + fallback_fmt = "html" if self.dformat == "plain" else "plain" + self.mformat = self.dformat \ + if self.dformat in self.bs.contents else fallback_fmt + return self._msg + + @property + def headers_as_list(self): + return [hdr[0].upper() for hdr in self.headernames] + + @property + def headers_as_text(self): + return " ".join(self.headers_as_list) + + @property + def body(self): + """Load email's body. + + This operation has to be made "on demand" because it requires + a communication with the IMAP server. + + """ + if self._body is None and self.bs.contents: + bodyc = u'' + for part in self.bs.contents[self.mformat]: + pnum = part['pnum'] + data = self.imapc._cmd( + "FETCH", self.mailid, "(BODY.PEEK[%s])" % pnum + ) + content = decode_payload( + part['encoding'], data[int(self.mailid)]['BODY[%s]' % pnum] + ) + charset = self._find_content_charset(part) + if charset is not None: + try: + content = content.decode(charset) + except (UnicodeDecodeError, LookupError): + result = chardet.detect(content) + content = content.decode(result['encoding']) + bodyc += content + self._fetch_inlines() + self._body = getattr(self, "viewmail_%s" % self.mformat)( + bodyc, links=self.links + ) + return self._body + + @body.setter + def body(self, value): + self._body = value + + def _find_content_charset(self, part): + for pos, elem in enumerate(part["params"]): + if elem == "charset": + return part["params"][pos + 1] + 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"] + if "params" in att and att["params"] != "NIL": + 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): + for cid, params in self.bs.inlines.iteritems(): + if re.search(r"\.\.", cid): + continue + fname = "webmail/%s_%s" % (self.mailid, cid) + path = os.path.join(settings.MEDIA_ROOT, fname) + params["fname"] = os.path.join(settings.MEDIA_URL, fname) + if os.path.exists(path): + continue + + pdef, content = self.imapc.fetchpart( + self.mailid, self.mbox, params["pnum"] + ) + with open(path, "wb") as fpo: + fpo.write(decode_payload(params["encoding"], content)) + + def map_cid(self, url): + m = re.match(".*cid:(.+)", url) + if m: + if m.group(1) in self.bs.inlines: + return self.bs.inlines[m.group(1)]["fname"] + return url + + def render_headers(self, **kwargs): + from django.template.loader import render_to_string + + res = render_to_string("webmail/headers.html", { + "headers": self.headers, + "folder": kwargs["folder"], "mail_id": kwargs["mail_id"], + "attachments": self.attachments != {} and self.attachments or None + }) + return res + + def fetch_attachment(self, pnum): + """Fetch an attachment from the IMAP server. + """ + return self.imapc.fetchpart(self.mailid, self.mbox, pnum) + + +class Modifier(ImapEmail): + def __init__(self, form, *args, **kwargs): + kwargs["dformat"] = "EDITOR" + super(Modifier, self).__init__(*args, **kwargs) + self.form = form + getattr(self, "_modify_%s" % self.dformat)() + + def _modify_plain(self): + self.body = re.sub("", "", self.body) + + def _modify_html(self): + pass + + +class ReplyModifier(Modifier): + headernames = ImapEmail.headernames + \ + [("Reply-To", True), + ("Message-ID", False)] + + def __init__(self, *args, **kwargs): + super(ReplyModifier, self).__init__(*args, **kwargs) + + self.textheader = "%s %s" % (self.From, _("wrote:")) + if hasattr(self, "Message_ID"): + self.form.fields["origmsgid"].initial = self.Message_ID + if not hasattr(self, "Reply_To"): + self.form.fields["to"].initial = self.From + else: + self.form.fields["to"].initial = self.Reply_To + if self.request.GET.get("all", "0") == "1": # reply-all + self.form.fields["cc"].initial = "" + toparse = self.To.split(",") + if hasattr(self, 'Cc'): + toparse += self.Cc.split(",") + for addr in toparse: + tmp = EmailAddress(addr) + if tmp.address and tmp.address == self.request.user.username: + continue + if self.form.fields["cc"].initial != "": + self.form.fields["cc"].initial += ", " + self.form.fields["cc"].initial += tmp.fulladdress + m = re.match(r"re\s*:\s*.+", self.Subject.lower()) + if m: + self.form.fields["subject"].initial = self.Subject + else: + self.form.fields["subject"].initial = "Re: %s" % self.Subject + + def _modify_plain(self): + super(ReplyModifier, self)._modify_plain() + lines = self.body.split('\n') + body = "" + for l in lines: + if body != "": + body += "\n" + body += ">%s" % l + self.body = body + + +class ForwardModifier(Modifier): + def __init__(self, *args, **kwargs): + super(ForwardModifier, self).__init__(*args, **kwargs) + self._header() + self.form.fields["subject"].initial = "Fwd: %s" % self.Subject + + def __getfunc(self, name): + return getattr(self, "%s_%s" % (name, self.dformat)) + + def _header(self): + self.textheader = self.__getfunc("_header_begin")() + "\n" + self.textheader += \ + self.__getfunc("_header_line")(_("Subject"), self.Subject) + self.textheader += \ + self.__getfunc("_header_line")(_("Date"), self.Date) + for hdr in ["From", "To", "Reply-To"]: + try: + key = re.sub("-", "_", hdr) + value = getattr(self, key) + self.textheader += \ + self.__getfunc("_header_line")(_(hdr), value) + except AttributeError: + pass + self.textheader += self.__getfunc("_header_end")() + + def _header_begin_plain(self): + return "----- %s -----" % _("Original message") + + def _header_begin_html(self): + return "----- %s -----" % _("Original message") + + def _header_line_plain(self, key, value): + return "%s: %s\n" % (key, value) + + def _header_line_html(self, key, value): + return "

%s: %s

" % (key, value) + + def _header_end_plain(self): + return "\n" + + def _header_end_html(self): + return "" diff --git a/modoboa/extensions/webmail/lib/imapheader.py b/modoboa/extensions/webmail/lib/imapheader.py new file mode 100644 index 000000000..2106ef8e3 --- /dev/null +++ b/modoboa/extensions/webmail/lib/imapheader.py @@ -0,0 +1,114 @@ +""" +Set of functions used to parse and transform email headers. +""" +import chardet +from modoboa.lib.emailutils import EmailAddress + +__all__ = [ + 'parse_from', 'parse_to', 'parse_message_id', 'parse_date', + 'parse_reply_to', 'parse_cc', 'parse_subject' +] + + +def to_unicode(value): + """Try to convert a string to unicode. + """ + if value is None or type(value) is unicode: + return value + try: + value = value.decode('utf-8') + except UnicodeDecodeError: + pass + else: + return value + try: + res = chardet.detect(value) + except UnicodeDecodeError: + return value + if res["encoding"] == "ascii": + return value + return value.decode(res["encoding"]) + + +def parse_address(value, **kwargs): + """Parse an email address. + """ + addr = EmailAddress(value) + if "full" in kwargs.keys() and kwargs["full"]: + return to_unicode(addr.fulladdress) + result = addr.name and addr.name or addr.fulladdress + return to_unicode(result) + + +def parse_address_list(values, **kwargs): + """Parse a list of email addresses. + """ + lst = values.split(",") + result = "" + for addr in lst: + if result != "": + result += ", " + result += parse_address(addr, **kwargs) + return result + + +def parse_from(value, **kwargs): + """Parse a From: header. + """ + return parse_address(value, **kwargs) + + +def parse_to(value, **kwargs): + """Parse a To: header. + """ + return parse_address_list(value, **kwargs) + + +def parse_cc(value, **kwargs): + """Parse a Cc: header. + """ + return parse_address_list(value, **kwargs) + + +def parse_reply_to(value, **kwargs): + """Parse a Reply-To: header. + """ + return parse_address_list(value, **kwargs) + + +def parse_date(value, **kwargs): + """Parse a Date: header. + """ + import datetime + import email + + tmp = email.utils.parsedate_tz(value) + if not tmp: + return value + ndate = datetime.datetime(*(tmp)[:7]) + now = datetime.datetime.now() + try: + if now - ndate > datetime.timedelta(7): + return ndate.strftime("%d.%m.%Y %H:%M") + return ndate.strftime("%a %H:%M") + except ValueError: + return value + + +def parse_message_id(value, **kwargs): + """Parse a Message-ID: header. + """ + return value.strip('\n') + + +def parse_subject(value, **kwargs): + """Parse a Subject: header. + """ + from modoboa.lib import u2u_decode + + try: + subject = u2u_decode.u2u_decode(value) + except UnicodeDecodeError: + subject = value + return to_unicode(subject) + diff --git a/modoboa/extensions/webmail/imaputils.py b/modoboa/extensions/webmail/lib/imaputils.py similarity index 99% rename from modoboa/extensions/webmail/imaputils.py rename to modoboa/extensions/webmail/lib/imaputils.py index 67065363a..c32584b13 100644 --- a/modoboa/extensions/webmail/imaputils.py +++ b/modoboa/extensions/webmail/lib/imaputils.py @@ -14,8 +14,8 @@ from modoboa.lib import parameters, imap_utf7 from modoboa.lib.exceptions import InternalError from modoboa.lib.connections import ConnectionsManager from modoboa.lib.webutils import static_url -from exceptions import ImapError, WebmailInternalError -from fetch_parser import parse_fetch_response +from ..exceptions import ImapError, WebmailInternalError +from .fetch_parser import parse_fetch_response #imaplib.Debug = 4 diff --git a/modoboa/extensions/webmail/lib/listing.py b/modoboa/extensions/webmail/lib/listing.py new file mode 100644 index 000000000..6a49fa149 --- /dev/null +++ b/modoboa/extensions/webmail/lib/listing.py @@ -0,0 +1,107 @@ +from django.conf import settings +from django.utils.translation import ugettext_lazy + +from modoboa.lib import parameters, tables +from modoboa.lib.email_listing import EmailListing +from . import IMAPconnector, imapheader + + +class WMtable(tables.Table): + """ + Webmail listing table. + """ + tableid = "emails" + styles = "table-condensed" + idkey = "imapid" + drag = tables.ImgColumn( + "drag", cssclass="draggable left", width="1%", + defvalue="%spics/grippy.png" % settings.STATIC_URL, + header="" + ) + selection = tables.SelectionColumn( + "selection", safe=True, width='1%', header=None, sortable=False + ) + flags = tables.ImgColumn("flags", width="3%") + withatts = tables.ImgColumn("withatts", width="2%") + subject = tables.Column( + "subject", label=ugettext_lazy("Subject"), width="50%", limit=60, + cssclass="openable" + ) + from_ = tables.Column( + "from", width="20%", label=ugettext_lazy("From"), limit=30, + cssclass="openable" + ) + date = tables.Column( + "date", width="15%", label=ugettext_lazy("Date"), cssclass="openable" + ) + + cols_order = [ + "drag", "selection", "withatts", "flags", "subject", "from_", "date" + ] + + def parse(self, header, value): + if value is None: + return "" + try: + value = getattr(imapheader, "parse_%s" % header)(value) + except AttributeError: + pass + return value + + +class ImapListing(EmailListing): + tpl = "webmail/index.html" + tbltype = WMtable + deflocation = "INBOX/" + defcallback = "wm_updatelisting" + reset_wm_url = False + + def __init__(self, user, password, **kwargs): + self.user = user + self.mbc = IMAPconnector(user=user.username, password=password) + if "pattern" in kwargs and kwargs["pattern"]: + self.parse_search_parameters( + kwargs["criteria"], kwargs["pattern"] + ) + else: + self.mbc.criterions = [] + + super(ImapListing, self).__init__(**kwargs) + self.extravars["refreshrate"] = \ + int(parameters.get_user(user, "REFRESH_INTERVAL")) * 1000 + + def parse_search_parameters(self, criterion, pattern): + + def or_criterion(old, c): + if old == "": + return c + return "OR (%s) (%s)" % (old, c) + + if criterion == u"both": + criterion = u"from_addr, subject" + criterions = "" + if type(pattern) is unicode: + pattern = pattern.encode("utf-8") + if type(criterion) is unicode: + criterion = criterion.encode("utf-8") + for c in criterion.split(','): + if c == "from_addr": + key = "FROM" + elif c == "subject": + key = "SUBJECT" + else: + continue + criterions = \ + or_criterion(criterions, '(%s "%s")' % (key, pattern)) + self.mbc.criterions = [criterions] + + @staticmethod + def computequota(mbc): + try: + return int(float(mbc.quota_actual) \ + / float(mbc.quota_limit) * 100) + except (AttributeError, TypeError): + return -1 + + def getquota(self): + return ImapListing.computequota(self.mbc) diff --git a/modoboa/extensions/webmail/lib/sendmail.py b/modoboa/extensions/webmail/lib/sendmail.py new file mode 100644 index 000000000..ba654cc02 --- /dev/null +++ b/modoboa/extensions/webmail/lib/sendmail.py @@ -0,0 +1,65 @@ +import smtplib +from modoboa.lib import parameters +from modoboa.lib.emailutils import prepare_addresses +from modoboa.lib.webutils import _render_to_string +from modoboa.lib.cryptutils import get_password +from modoboa.extensions.webmail.exceptions import WebmailInternalError +from . import get_imapconnector, clean_attachments + + +def send_mail(request, form, posturl=None): + """Email verification and sending. + + If the form does not present any error, a new MIME message is + constructed. Then, a connection is established with the defined + SMTP server and the message is finally sent. + + :param request: a Request object + :param posturl: the url to post the message form to + :return: a 2-uple (True|False, HttpResponse) + """ + if not form.is_valid(): + editormode = parameters.get_user(request.user, "EDITOR") + listing = _render_to_string( + request, "webmail/compose.html", + {"form": form, "noerrors": True, + "body": form.cleaned_data.get("body", "").strip(), + "posturl": posturl} + ) + return False, dict(status="ko", listing=listing, editor=editormode) + + msg = form.to_msg(request) + rcpts = prepare_addresses(form.cleaned_data["to"], "envelope") + for hdr in ["cc", "cci"]: + if form.cleaned_data[hdr]: + msg[hdr.capitalize()] = prepare_addresses(form.cleaned_data[hdr]) + rcpts += prepare_addresses(form.cleaned_data[hdr], "envelope") + try: + secmode = parameters.get_admin("SMTP_SECURED_MODE") + if secmode == "ssl": + s = smtplib.SMTP_SSL(parameters.get_admin("SMTP_SERVER"), + int(parameters.get_admin("SMTP_PORT"))) + else: + s = smtplib.SMTP(parameters.get_admin("SMTP_SERVER"), + int(parameters.get_admin("SMTP_PORT"))) + if secmode == "starttls": + s.starttls() + except Exception as text: + raise WebmailInternalError(str(text)) + + if parameters.get_admin("SMTP_AUTHENTICATION") == "yes": + try: + s.login(request.user.username, get_password(request)) + except smtplib.SMTPException as err: + raise WebmailInternalError(str(err)) + try: + s.sendmail(request.user.email, rcpts, msg.as_string()) + s.quit() + except smtplib.SMTPException as err: + raise WebmailInternalError(str(err)) + + sentfolder = parameters.get_user(request.user, "SENT_FOLDER") + get_imapconnector(request).push_mail(sentfolder, msg) + clean_attachments(request.session["compose_mail"]["attachments"]) + del request.session["compose_mail"] + return True, {} diff --git a/modoboa/extensions/webmail/lib/signature.py b/modoboa/extensions/webmail/lib/signature.py new file mode 100644 index 000000000..359801a1f --- /dev/null +++ b/modoboa/extensions/webmail/lib/signature.py @@ -0,0 +1,29 @@ +import re +from modoboa.lib import parameters + + +class EmailSignature(object): + """User signature + + :param user: User object + """ + def __init__(self, user): + self._sig = "" + dformat = parameters.get_user(user, "EDITOR") + content = parameters.get_user(user, "SIGNATURE") + if len(content): + getattr(self, "_format_sig_%s" % dformat)(content) + + def _format_sig_plain(self, content): + self._sig = """ +--- +%s""" % content + + def _format_sig_html(self, content): + content = re.sub("\n", "
", content) + self._sig = """
+---
+%s""" % content + + def __repr__(self): + return self._sig diff --git a/modoboa/extensions/webmail/lib/tables.py b/modoboa/extensions/webmail/lib/tables.py new file mode 100644 index 000000000..4830c6799 --- /dev/null +++ b/modoboa/extensions/webmail/lib/tables.py @@ -0,0 +1,48 @@ +from django.conf import settings +from django.utils.translation import ugettext_lazy + +from modoboa.lib import tables +from . import imapheader + + +class WMtable(tables.Table): + """ + The webmail listing. + """ + tableid = "emails" + styles = "table-condensed" + idkey = "imapid" + drag = tables.ImgColumn( + "drag", cssclass="draggable left", width="1%", + defvalue="%spics/grippy.png" % settings.STATIC_URL, + header="" + ) + selection = tables.SelectionColumn( + "selection", safe=True, width='1%', header=None, sortable=False + ) + flags = tables.ImgColumn("flags", width="3%") + withatts = tables.ImgColumn("withatts", width="2%") + subject = tables.Column( + "subject", label=ugettext_lazy("Subject"), width="50%", limit=60, + cssclass="openable" + ) + from_ = tables.Column( + "from", width="20%", label=ugettext_lazy("From"), limit=30, + cssclass="openable" + ) + date = tables.Column( + "date", width="15%", label=ugettext_lazy("Date"), cssclass="openable" + ) + + cols_order = [ + "drag", "selection", "withatts", "flags", "subject", "from_", "date" + ] + + def parse(self, header, value): + if value is None: + return "" + try: + value = getattr(imapheader, "parse_%s" % header)(value) + except AttributeError: + pass + return value diff --git a/modoboa/extensions/webmail/lib/utils.py b/modoboa/extensions/webmail/lib/utils.py new file mode 100644 index 000000000..fc2569ad9 --- /dev/null +++ b/modoboa/extensions/webmail/lib/utils.py @@ -0,0 +1,35 @@ +""" +Misc. utilities. +""" +from modoboa.lib.webutils import NavigationParameters + + +def decode_payload(encoding, payload): + """Decode the payload according to the given encoding + + Supported encodings: base64, quoted-printable. + + :param encoding: the encoding's name + :param payload: the value to decode + :return: a string + """ + encoding = encoding.lower() + if encoding == "base64": + import base64 + return base64.b64decode(payload) + elif encoding == "quoted-printable": + import quopri + return quopri.decodestring(payload) + return payload + + +class WebmailNavigationParameters(NavigationParameters): + """ + Specific NavigationParameters subclass for the webmail. + """ + def __init__(self, request, defmailbox=None): + super(WebmailNavigationParameters, self).__init__( + request, 'webmail_navparams' + ) + if defmailbox is not None: + self.parameters += [('mbox', defmailbox, False)] diff --git a/modoboa/extensions/webmail/static/webmail/js/webmail.js b/modoboa/extensions/webmail/static/webmail/js/webmail.js index 14ea120c7..fc7b468f4 100644 --- a/modoboa/extensions/webmail/static/webmail/js/webmail.js +++ b/modoboa/extensions/webmail/static/webmail/js/webmail.js @@ -753,7 +753,7 @@ Webmail.prototype = { }, this)).fail($.proxy(function(jqxhr) { var data = $.parseJSON(jqxhr.responseText); if (data != undefined) { - $("body").notify("error", data); + this.compose_callback(data); } $("a[name=sendmail]").attr("disabled", null); }, this)); diff --git a/modoboa/extensions/webmail/templates/webmail/compose.html b/modoboa/extensions/webmail/templates/webmail/compose.html index a05a47d3b..9deee9786 100644 --- a/modoboa/extensions/webmail/templates/webmail/compose.html +++ b/modoboa/extensions/webmail/templates/webmail/compose.html @@ -23,11 +23,11 @@
{% if body %} - {% else %} - + {% endif %}
diff --git a/modoboa/extensions/webmail/templatetags/webmail_tags.py b/modoboa/extensions/webmail/templatetags/webmail_tags.py index 49a907af6..2c32b274a 100644 --- a/modoboa/extensions/webmail/templatetags/webmail_tags.py +++ b/modoboa/extensions/webmail/templatetags/webmail_tags.py @@ -5,7 +5,7 @@ from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse from django.conf import settings from modoboa.extensions import webmail -from modoboa.extensions.webmail.imaputils import separate_mailbox +from modoboa.extensions.webmail.lib import separate_mailbox from modoboa.lib import parameters register = template.Library() diff --git a/modoboa/extensions/webmail/views.py b/modoboa/extensions/webmail/views.py index 730a1e4d3..5f5088ac3 100644 --- a/modoboa/extensions/webmail/views.py +++ b/modoboa/extensions/webmail/views.py @@ -17,13 +17,15 @@ from modoboa.lib.webutils import ( ) from modoboa.extensions.admin.lib import needs_mailbox from .exceptions import UnknownAction -from .forms import FolderForm, AttachmentForm, ComposeMailForm -from .imaputils import get_imapconnector, IMAPconnector, separate_mailbox +from .forms import ( + FolderForm, AttachmentForm, ComposeMailForm, ForwardMailForm +) from .lib import ( decode_payload, AttachmentUploadHandler, save_attachment, ImapListing, EmailSignature, clean_attachments, set_compose_session, send_mail, - ImapEmail, WebmailNavigationParameters, ReplyModifier, ForwardModifier + ImapEmail, WebmailNavigationParameters, ReplyModifier, ForwardModifier, + get_imapconnector, IMAPconnector, separate_mailbox ) from templatetags import webmail_tags @@ -382,43 +384,62 @@ def render_compose(request, form, posturl, email=None, insert_signature=False): def compose(request): url = "?action=compose" if request.method == "POST": - status, resp = send_mail(request, posturl=url) + form = ComposeMailForm(request.POST) + status, resp = send_mail(request, form, posturl=url) return resp form = ComposeMailForm() return render_compose(request, form, url, insert_signature=True) -def compose_and_send(request, action, callback=None): +def get_mail_info(request): + """Retrieve a mailbox and an email ID from a request. + """ mbox = request.GET.get("mbox", None) mailid = request.GET.get("mailid", None) if mbox is None or mailid is None: raise BadRequest(_("Invalid request")) - url = "?action=%s&mbox=%s&mailid=%s" % (action, mbox, mailid) - if request.method == "POST": - status, resp = send_mail(request, url) - if status and callback: - callback(mbox, mailid) - return resp + return mbox, mailid + +def new_compose_form(request, action, mbox, mailid): + """Return a new composition form. + + Valid for reply and forward actions only. + """ form = ComposeMailForm() modclass = globals()["%sModifier" % action.capitalize()] - email = modclass(mbox, mailid, request, form, addrfull=True, links="1") + email = modclass(form, request, True, "%s:%s" % (mbox, mailid), links="1") + url = "?action=%s&mbox=%s&mailid=%s" % (action, mbox, mailid) return render_compose(request, form, url, email) def reply(request): - def msg_replied(mbox, mailid): - get_imapconnector(request).msg_answered(mbox, mailid) - - return compose_and_send(request, "reply", msg_replied) + """Reply to email. + """ + mbox, mailid = get_mail_info(request) + if request.method == "POST": + url = "?action=reply&mbox=%s&mailid=%s" % (mbox, mailid) + form = ComposeMailForm(request.POST) + status, resp = send_mail(request, form, url) + if status: + get_imapconnector(request).msg_answered(mbox, mailid) + return resp + return new_compose_form(request, "reply", mbox, mailid) def forward(request): - def msg_forwarded(mbox, mailid): - get_imapconnector(request).msg_forwarded(mbox, mailid) - - return compose_and_send(request, "forward", msg_forwarded) + """Forward email. + """ + mbox, mailid = get_mail_info(request) + if request.method == "POST": + url = "?action=forward&mbox=%s&mailid=%s" % (mbox, mailid) + form = ForwardMailForm(request.POST) + status, resp = send_mail(request, form, url) + if status: + get_imapconnector(request).msg_forwarded(mbox, mailid) + return resp + return new_compose_form(request, "forward", mbox, mailid) @login_required @@ -428,7 +449,10 @@ def getmailcontent(request): mailid = request.GET.get("mailid", None) if mbox is None or mailid is None: raise BadRequest(_("Invalid request")) - email = ImapEmail(mbox, mailid, request, links=int(request.GET["links"])) + email = ImapEmail( + request, False, "%s:%s" % (mbox, mailid), dformat="DISPLAYMODE", + links=int(request.GET["links"]) + ) return render(request, "common/viewmail.html", { "headers": email.render_headers(folder=mbox, mail_id=mailid), "folder": mbox, "imapid": mailid, diff --git a/modoboa/lib/emailutils.py b/modoboa/lib/emailutils.py index 0f298d9bd..e0605b9fc 100644 --- a/modoboa/lib/emailutils.py +++ b/modoboa/lib/emailutils.py @@ -5,9 +5,10 @@ import smtplib from email.header import Header, decode_header from email.mime.text import MIMEText from email.utils import make_msgid, formatdate, parseaddr +import lxml.html from django.template.loader import render_to_string from django.conf import settings -import u2u_decode +from modoboa.lib import u2u_decode class EmailAddress(object): @@ -22,26 +23,49 @@ class EmailAddress(object): class Email(object): - def __init__(self, msg, mformat="plain", dformat="plain", links=0): + def __init__(self, mailid, mformat="plain", dformat="plain", links=0): self.attached_map = {} self.contents = {"html": "", "plain": ""} self.headers = [] self.attachments = {} + self.mailid = mailid self.mformat = mformat self.dformat = dformat self.links = links - - self.__parse(msg) + self._msg = None + self._body = None if not mformat in self.contents or self.contents[mformat] == "": # Fallback self.mformat = mformat == "html" and "plain" or "html" - self.body = \ - getattr(self, "viewmail_%s" % self.mformat) \ - (self.contents[self.mformat], links=links) + @property + def msg(self): + """Return an email.message object. + """ + raise NotImplementedError - def __parse_default(self, msg, level): + @property + def body(self): + """Return email's body. + """ + if self._body is None: + self._body = getattr(self, "viewmail_%s" % self.mformat) \ + (self.contents[self.mformat], links=self.links) + return self._body + + def get_header(self, msg, hdrname): + """Look for a particular header. + + :param string hdrname: header name + :return: header avalue + """ + for name in [hdrname, hdrname.upper()]: + if name in msg: + return msg[name] + return "" + + def _parse_default(self, msg, level): """Default parser All parts handled by this parser will be consireded as @@ -63,7 +87,7 @@ class Email(object): inside a navigator. """ if msg.get_content_subtype() not in ["plain", "html"]: - self.__parse_default(msg, level) + self._parse_default(msg, level) target = "plain" else: target = msg.get_content_subtype() @@ -96,9 +120,9 @@ class Email(object): self.attached_map[cid] = re.match("^http:", fname) and fname \ or self.__save_image(fname, msg) return - self.__parse_default(msg, level) + self._parse_default(msg, level) - def __parse(self, msg, level=None): + def _parse(self, msg, level=None): """Recursive email parser A message structure can be complex. To correctly handle @@ -116,7 +140,7 @@ class Email(object): for part in msg.get_payload(): nlevel = level is None and ("%d" % cpt) \ or "%s.%d" % (level, cpt) - self.__parse(part, nlevel) + self._parse(part, nlevel) cpt += 1 return @@ -125,7 +149,7 @@ class Email(object): try: getattr(self, "_parse_%s" % msg.get_content_maintype())(msg, level) except AttributeError: - self.__parse_default(msg, level) + self._parse_default(msg, level) def __save_image(self, fname, part): """Save an inline image on the filesystem. @@ -138,7 +162,7 @@ class Email(object): :param fname: the image associated filename :param part: the email part that contains the image payload """ - if re.search("\.\.", fname): + if re.search(r"\.\.", fname): return None path = "/static/tmp/" + fname fp = open(settings.MODOBOA_DIR + path, "wb") diff --git a/modoboa/lib/u2u_decode.py b/modoboa/lib/u2u_decode.py index 751f1022d..7ff7c8fee 100644 --- a/modoboa/lib/u2u_decode.py +++ b/modoboa/lib/u2u_decode.py @@ -27,7 +27,10 @@ def decode_mime(m): """substitute matching encoded_word with unicode equiv. """ h = decode_header(clean_spaces(m)) - u = unicode(make_header(h)) + try: + u = unicode(make_header(h)) + except UnicodeDecodeError: + return m.group(0) return u From a9d5c68b21a95d5215465c7635ffaf43dc89bfe1 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 1 May 2014 15:13:23 +0200 Subject: [PATCH 12/18] Wrong import statements fixed. see #545 --- modoboa/extensions/sievefilters/forms.py | 2 +- modoboa/extensions/sievefilters/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modoboa/extensions/sievefilters/forms.py b/modoboa/extensions/sievefilters/forms.py index bcbebd7f4..afa3200e4 100644 --- a/modoboa/extensions/sievefilters/forms.py +++ b/modoboa/extensions/sievefilters/forms.py @@ -173,7 +173,7 @@ class FilterForm(forms.Form): return ret def userfolders(self, request): - from modoboa.extensions.webmail.imaputils import get_imapconnector + from modoboa.extensions.webmail.lib import get_imapconnector mbc = get_imapconnector(request) ret = mbc.getmboxes(request.user, unseen_messages=False) diff --git a/modoboa/extensions/sievefilters/views.py b/modoboa/extensions/sievefilters/views.py index 7c5435251..c24fe8d39 100644 --- a/modoboa/extensions/sievefilters/views.py +++ b/modoboa/extensions/sievefilters/views.py @@ -19,13 +19,13 @@ from .forms import ( FilterForm, build_filter_form_from_qdict, build_filter_form_from_filter, FiltersSetForm ) -from templatetags.sfilters_tags import fset_menu +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 + from modoboa.extensions.webmail.lib import get_imapconnector try: sc = SieveClient(user=request.user.username, From 1ab2e31fe6c36e5566692a4a288231429873fe81 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 1 May 2014 15:41:07 +0200 Subject: [PATCH 13/18] Only decode when necessary. fix #545 --- modoboa/extensions/webmail/lib/attachments.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modoboa/extensions/webmail/lib/attachments.py b/modoboa/extensions/webmail/lib/attachments.py index b891df0a9..11ab08f93 100644 --- a/modoboa/extensions/webmail/lib/attachments.py +++ b/modoboa/extensions/webmail/lib/attachments.py @@ -85,7 +85,9 @@ def create_mail_attachment(attdef, payload=None): else: res.set_payload(payload) Encoders.encode_base64(res) - res['Content-Disposition'] = build_header(attdef['fname'].decode('utf-8')) + if type(attdef['fname']) is str: + attdef['fname'] = attdef['fname'].decode('utf-8') + res['Content-Disposition'] = build_header(attdef['fname']) return res From 3a01b40657a69780461cd1748a56c992f88fa199 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 1 May 2014 15:56:00 +0200 Subject: [PATCH 14/18] Disallow # character in filter names. fix #550 --- modoboa/extensions/sievefilters/forms.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modoboa/extensions/sievefilters/forms.py b/modoboa/extensions/sievefilters/forms.py index afa3200e4..11047a395 100644 --- a/modoboa/extensions/sievefilters/forms.py +++ b/modoboa/extensions/sievefilters/forms.py @@ -97,6 +97,13 @@ class FilterForm(forms.Form): for a in actions: getattr(self, "_build_%s_field" % a[0])(request, a[1]) + def clean_name(self): + """Check that name does not contain strange chars. + """ + if '#' in self.cleaned_data["name"]: + raise forms.ValidationError(_("Wrong filter name")) + return self.cleaned_data["name"] + def _build_header_field(self, name, op, value): targets = [] ops = [] From bf0bffad909016a9bdabb09fc4944db585d374e5 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 1 May 2014 17:54:21 +0200 Subject: [PATCH 15/18] Adapt identities state based on domain's state. fix #542 --- modoboa/extensions/admin/forms/account.py | 33 +++++++++++++++---- modoboa/extensions/admin/forms/alias.py | 5 +++ .../templates/admin/account_general_form.html | 12 +++++++ .../admin/templates/admin/aliasform.html | 4 +++ .../templates/admin/identities_table.html | 2 +- .../admin/templatetags/admin_tags.py | 15 +++++++++ modoboa/templates/common/generic_field.html | 4 +-- 7 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 modoboa/extensions/admin/templates/admin/account_general_form.html diff --git a/modoboa/extensions/admin/forms/account.py b/modoboa/extensions/admin/forms/account.py index 3c9f3d7c0..5056c7660 100644 --- a/modoboa/extensions/admin/forms/account.py +++ b/modoboa/extensions/admin/forms/account.py @@ -6,7 +6,7 @@ from modoboa.lib.exceptions import PermDeniedException, Conflict, NotFound from modoboa.lib.permissions import get_account_roles from modoboa.lib.emailutils import split_mailbox from modoboa.lib.formutils import ( - DomainNameField, DynamicForm, TabForms, YesNoField + DomainNameField, DynamicForm, TabForms ) from modoboa.core.models import User from modoboa.extensions.admin.models import ( @@ -58,18 +58,35 @@ class AccountFormGeneral(forms.ModelForm): if 'instance' in kwargs else get_account_roles(user) if "instance" in kwargs: - if args \ - and (args[0].get("password1", "") == "" - and args[0].get("password2", "") == ""): - self.fields["password1"].required = False - self.fields["password2"].required = False account = kwargs["instance"] + domain_disabled = account.mailbox_set.count() and \ + not account.mailbox_set.all()[0].domain.enabled + if domain_disabled: + self.fields["is_active"].widget.attrs['disabled'] = "disabled" + if args: + if args[0].get("password1", "") == "" \ + and args[0].get("password2", "") == "": + self.fields["password1"].required = False + self.fields["password2"].required = False + if domain_disabled: + del self.fields["is_active"] self.fields["role"].initial = account.group if not account.is_local \ and parameters.get_admin("LDAP_AUTH_METHOD", app="core") == "directbind": del self.fields["password1"] del self.fields["password2"] + def domain_is_disabled(self): + """Little shortcut to get the domain's state. + + We need this information inside a template and the form is the + only object available... + + """ + if not self.instance.mailbox_set.count(): + return False + return self.instance.mailbox_set.all()[0].domain.enabled == False + def clean_role(self): if self.user.group == "DomainAdmins": if self.instance == self.user: @@ -323,7 +340,9 @@ class AccountForm(TabForms): def __init__(self, user, *args, **kwargs): self.user = user self.forms = [ - dict(id="general", title=_("General"), cls=AccountFormGeneral, + dict(id="general", title=_("General"), + formtpl="admin/account_general_form.html", + cls=AccountFormGeneral, new_args=[user], mandatory=True), dict(id="mail", title=_("Mail"), formtpl="admin/mailform.html", cls=AccountFormMail), diff --git a/modoboa/extensions/admin/forms/alias.py b/modoboa/extensions/admin/forms/alias.py index 555b98802..a16b14257 100644 --- a/modoboa/extensions/admin/forms/alias.py +++ b/modoboa/extensions/admin/forms/alias.py @@ -37,10 +37,15 @@ class AliasForm(forms.ModelForm, DynamicForm): self.fields.keyOrder = ['email', 'recipients', 'enabled'] if len(args) and isinstance(args[0], QueryDict): + if "instance" in kwargs: + if not kwargs["instance"].domain.enabled: + del self.fields["enabled"] self._load_from_qdict(args[0], "recipients", forms.EmailField) elif "instance" in kwargs: dlist = kwargs["instance"] self.fields["email"].initial = dlist.full_address + if not dlist.domain.enabled: + self.fields["enabled"].widget.attrs["disabled"] = "disabled" cpt = 1 for al in dlist.aliases.all(): name = "recipients_%d" % cpt diff --git a/modoboa/extensions/admin/templates/admin/account_general_form.html b/modoboa/extensions/admin/templates/admin/account_general_form.html new file mode 100644 index 000000000..a7980337b --- /dev/null +++ b/modoboa/extensions/admin/templates/admin/account_general_form.html @@ -0,0 +1,12 @@ +{% load lib_tags %} +{% render_field form.role %} +{% render_field form.username %} +{% render_field form.first_name %} +{% render_field form.last_name %} +{% render_field form.password1 %} +{% render_field form.password2 %} +{% if form.domain_is_disabled %} +{% include "common/generic_field.html" with field=form.is_active inline_message=_("Domain is disabled") extra_classes="info" %} +{% else %} +{% render_field form.is_active %} +{% endif %} diff --git a/modoboa/extensions/admin/templates/admin/aliasform.html b/modoboa/extensions/admin/templates/admin/aliasform.html index 6a13e26c9..b82adfbf9 100644 --- a/modoboa/extensions/admin/templates/admin/aliasform.html +++ b/modoboa/extensions/admin/templates/admin/aliasform.html @@ -2,5 +2,9 @@ {% block formbody %} {% render_field form.email %} {% render_fields_group form "recipients" %} +{% if form.instance.domain.enabled %} {% render_field form.enabled %} +{% else %} +{% include "common/generic_field.html" with field=form.enabled inline_message=_("Domain is disabled") extra_classes="info" %} +{% endif %} {% endblock %} diff --git a/modoboa/extensions/admin/templates/admin/identities_table.html b/modoboa/extensions/admin/templates/admin/identities_table.html index 0d5200c6c..31d9693bb 100644 --- a/modoboa/extensions/admin/templates/admin/identities_table.html +++ b/modoboa/extensions/admin/templates/admin/identities_table.html @@ -10,7 +10,7 @@ {% for ident in identities %} - + {% identity_modify_link ident %} {{ ident.name_or_rcpt }} {% render_tags ident.tags %} diff --git a/modoboa/extensions/admin/templatetags/admin_tags.py b/modoboa/extensions/admin/templatetags/admin_tags.py index 3f934ddce..40d723203 100644 --- a/modoboa/extensions/admin/templatetags/admin_tags.py +++ b/modoboa/extensions/admin/templatetags/admin_tags.py @@ -256,3 +256,18 @@ def get_extra_admin_content(user, target, currentpage): "ExtraAdminContent", user, target, currentpage ) return "".join(res) + + +@register.simple_tag +def disable_identity(identity): + """Disable an identity. + + Finding this information depends on the identity type. + """ + if identity.__class__.__name__ == "User": + if identity.is_active and identity.mailbox_set.count() \ + and identity.mailbox_set.all()[0].domain.enabled: + return "" + elif identity.enabled and identity.domain.enabled: + return "" + return "muted" diff --git a/modoboa/templates/common/generic_field.html b/modoboa/templates/common/generic_field.html index 05dd55fc8..0f6e6bf36 100644 --- a/modoboa/templates/common/generic_field.html +++ b/modoboa/templates/common/generic_field.html @@ -1,9 +1,9 @@ {% load i18n core_tags lib_tags %} -