From 9ddb27573f8a1a7693f9837698d1ce28bfa22e2e Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Thu, 8 Sep 2011 09:13:13 +0200 Subject: [PATCH] At last, composing messages with attachments is possible!! It is a really simple implementation but I think it will be enough for a begining. Users can add multiple attachments with a message, each attachment must not be larger than a customizable limit. see #38 --- extensions/webmail/forms.py | 3 + extensions/webmail/lib.py | 225 +++++++++++++++++- extensions/webmail/main.py | 2 + extensions/webmail/static/css/attachments.css | 32 +++ extensions/webmail/static/css/webmail.css | 84 ++++++- extensions/webmail/static/js/fdmenu.js | 3 +- extensions/webmail/static/js/webmail.js | 67 ++++-- .../templates/webmail/attachments.html | 115 +++++++++ .../webmail/templates/webmail/compose.html | 14 ++ .../webmail/templates/webmail/folders.html | 2 +- .../templates/webmail/upload_done.html | 15 ++ extensions/webmail/urls.py | 9 + extensions/webmail/views.py | 210 +++++++--------- lib/parameters.py | 4 +- lib/webutils.py | 26 ++ static/js/SqueezeBox.js | 13 +- static/js/anchor_nav.js | 19 +- static/js/common.js | 22 ++ static/pics/attachment.png | Bin 0 -> 649 bytes templates/common/genericform.html | 4 +- userprefs/templatetags/uprefs_extras.py | 4 +- 21 files changed, 710 insertions(+), 163 deletions(-) create mode 100644 extensions/webmail/static/css/attachments.css create mode 100644 extensions/webmail/templates/webmail/attachments.html create mode 100644 extensions/webmail/templates/webmail/upload_done.html create mode 100644 static/pics/attachment.png diff --git a/extensions/webmail/forms.py b/extensions/webmail/forms.py index 1de6598e1..32ffe97ea 100644 --- a/extensions/webmail/forms.py +++ b/extensions/webmail/forms.py @@ -9,3 +9,6 @@ class ComposeMailForm(forms.Form): class FolderForm(forms.Form): name = forms.CharField() + +class AttachmentForm(forms.Form): + attachment = forms.FileField(label="") diff --git a/extensions/webmail/lib.py b/extensions/webmail/lib.py index f1c5c50b5..8600762ac 100644 --- a/extensions/webmail/lib.py +++ b/extensions/webmail/lib.py @@ -12,10 +12,12 @@ import ssl from datetime import datetime, timedelta import email import lxml +from django.core.files.uploadhandler import FileUploadHandler, StopUpload, SkipFile +from django.http import HttpResponse from django.utils.translation import ugettext as _ from django.conf import settings from modoboa.lib import u2u_decode, tables, imap_utf7, parameters -from modoboa.lib.webutils import static_url +from modoboa.lib.webutils import static_url, size2integer from modoboa.lib.connections import ConnectionsManager from modoboa.lib.email_listing import MBconnector, EmailListing from modoboa.lib.emailutils import * @@ -611,4 +613,225 @@ def find_images_in_body(body): 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, "tmp") + try: + fp = NamedTemporaryFile(dir=dstdir, delete=False) + except Exception, e: + raise WebmailError(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, "tmp", att["tmpname"]) + try: + os.remove(fullpath) + except OSError, e: + pass + +def html2plaintext(content): + """HTML to plain text translation + + :param content: some HTML content + """ + 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 get_current_url(request): + res = "%s?page=%s" % (request.session["folder"], request.session["page"]) + for p in ["criteria", "pattern", "order"]: + if p in request.session.keys(): + res += "&%s=%s" % (p, request.session[p]) + return res + +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, "tmp", attdef["tmpname"]), "rb") + res.set_payload(fp.read()) + fp.close() + Encoders.encode_base64(res) + res.add_header("Content-Disposition", "attachment; filename='%s'" % attdef["fname"]) + return res + +def send_mail(request, origmsg=None, 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 origmsg: the eventual original message (in case of a reply) + :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 getctx, ajax_simple_response + from modoboa.admin.models import Mailbox + from modoboa.auth.lib import get_password + + form = ComposeMailForm(request.POST) + editormode = parameters.get_user(request.user, "EDITOR") + if form.is_valid(): + from email.mime.text import MIMEText + from email.utils import make_msgid, formatdate + import smtplib + + body = request.POST["id_body"] + charset = "utf-8" + if editormode == "html": + msg = MIMEMultipart(_subtype="related") + submsg = MIMEMultipart(_subtype="alternative") + textbody = html2plaintext(body) + 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) + for img in images: + msg.attach(img) + else: + text = MIMEText(body.encode(charset), _subtype=editormode) + 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)) + + msg["Subject"] = form.cleaned_data["subject"] + address, domain = split_mailbox(form.cleaned_data["from_"]) + try: + mb = Mailbox.objects.get(address=address, domain__name=domain) + msg["From"] = "%s <%s>" % (mb.name, form.cleaned_data["from_"]) + except Mailbox.DoesNotExist: + msg["From"] = form.cleaned_data["from_"] + msg["To"] = form.cleaned_data["to"] + msg["Message-ID"] = make_msgid() + msg["User-Agent"] = "Modoboa" + msg["Date"] = formatdate(time.time(), True) + if origmsg and origmsg.has_key("Message-ID"): + msg["References"] = msg["In-Reply-To"] = origmsg["Message-ID"] + rcpts = msg['To'].split(',') + if form.cleaned_data["cc"] != "": + msg["Cc"] = form.cleaned_data["cc"] + rcpts += msg["Cc"].split(",") + + 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, text: + raise WebmailError(str(text)) + + if parameters.get_admin("SMTP_AUTHENTICATION") == "yes": + try: + s.login(request.user.username, get_password(request)) + except smtplib.SMTPAuthenticationError, e: + raise WebmailError(str(e)) + s.sendmail(msg['From'], rcpts, msg.as_string()) + s.quit() + 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, ajax_simple_response(getctx("ok", url=get_current_url(request))) + + + listing = render_to_string("webmail/compose.html", + {"form" : form, + "body" : request.POST["id_body"].strip(), + "posturl" : posturl}) + return False, ajax_simple_response( + getctx("ko", level=2, 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 diff --git a/extensions/webmail/main.py b/extensions/webmail/main.py index 77a86ec60..be689f7f4 100644 --- a/extensions/webmail/main.py +++ b/extensions/webmail/main.py @@ -31,6 +31,8 @@ def init(): parameters.register_admin("SMTP_AUTHENTICATION", type="list_yesno", deflt="no", help=_("Server needs authentication")) + parameters.register_admin("MAX_ATTACHMENT_SIZE", type="int", deflt=2048, + help=_("Maximum attachment size in KB")) parameters.register_user("MESSAGES_PER_PAGE", type="int", deflt=40, diff --git a/extensions/webmail/static/css/attachments.css b/extensions/webmail/static/css/attachments.css new file mode 100644 index 000000000..ce9eedd5a --- /dev/null +++ b/extensions/webmail/static/css/attachments.css @@ -0,0 +1,32 @@ +#attachment_list { + border: 1px solid #ccc; + background-color: #eee; + position: absolute; + top: 140px; + left: 20px; + right: 20px; + bottom: 35px; + overflow-x: hidden; + overflow-y: auto; + padding: 2px; +} + +#upload_status { + position: relative; + margin: 0 auto; + font-size: 0.9em; + width: 120px; + font-style: italic; +} + +#done_container { + position: absolute; + bottom: 0; + width: 100%; + text-align: center; +} + +img { + float: left; + margin-right: 5px; +} \ No newline at end of file diff --git a/extensions/webmail/static/css/webmail.css b/extensions/webmail/static/css/webmail.css index deb211ebc..b27847859 100644 --- a/extensions/webmail/static/css/webmail.css +++ b/extensions/webmail/static/css/webmail.css @@ -12,6 +12,29 @@ html > body #listing { bottom: 35px; } +.clearfix:after { + content: "."; + display: block; + clear: both; + visibility: hidden; + overflow: hidden; + line-height: 0; + height: 0; +} + +.clearfix { + display: inline-block; +} + +html[xmlns] .clearfix { + display: block; +} + +* html .clearfix { + height: 1%; + overflow: visible; +} + /*thead tr { position: relative; top: expression(offsetParent.scrollTop); @@ -66,27 +89,63 @@ ul.visible { /* Compose mail page */ #compose-container { - margin: 0 auto; - width: 100%; + /*margin: 0 auto;*/ + /*width: 100%;*/ + position: absolute; + left: 0px; + right: 10px; + top: 0; + bottom: 0; +} + +#attachments { + position: relative; + border: 1px solid #cccccc; + background-color: #eee; + margin-bottom: 5px; + padding: 5px 0; + cursor: pointer; +} + +#attachments img { + float: left; +} + +#list { + position: absolute; + top: 5px; + left: 130px; + font-weight: bold; +} + +.shortlist { + font-weight: normal !important; + font-style: italic; } div.row { - margin: 3px; - clear: both; + width: 100%; + height: 40px; + margin: 5px 0; } div.row label { - float: left; width: 120px; font-size: small; padding: 3px 0; } +div.input_container { + position: absolute; + left: 130px; + right: 0; +} + textarea { border: 1px solid #cccccc; background-color: #eee; - width: 80%; - height: 30px; + width: 100%; + height: 40px; word-wrap: break-word; } @@ -96,15 +155,22 @@ textarea:hover { #id_body { background: white; - width: 98%; + /*width: 98%;*/ + position: absolute; + left: 0; + right: 10px; } #mailheader input { - width: 80%; + width: 100%; padding: 0; + height: 20px; + clear: both; + } #mailheader { + position: relative; padding-bottom: 5px; } diff --git a/extensions/webmail/static/js/fdmenu.js b/extensions/webmail/static/js/fdmenu.js index e966b75ed..721d69ecd 100644 --- a/extensions/webmail/static/js/fdmenu.js +++ b/extensions/webmail/static/js/fdmenu.js @@ -33,7 +33,8 @@ var FdMenu = new Class({ evt.stop(); SqueezeBox.open(evt.target.get("href"), { handler: "iframe", - size: {x: 350, y: 400} + size: {x: 350, y: 400}, + closeBtn: true }); $(document.body).fireEvent("click"); }); diff --git a/extensions/webmail/static/js/webmail.js b/extensions/webmail/static/js/webmail.js index 9f61e42c8..8f2c11e03 100644 --- a/extensions/webmail/static/js/webmail.js +++ b/extensions/webmail/static/js/webmail.js @@ -86,6 +86,38 @@ function resize_window_callback(event) { setDivHeight("id_body", $("mailheader").getSize().y, 0); } +/* + * Callback of the 'sendmail' action. + */ +function sendmail_callback(evt) { + evt.stop(); + infobox.show(gettext("Sending..."), { + profile : "gray", + spinner : true + }); + disable_link(this); + $("composemail").set("send", { + onSuccess: function(resp) { + resp = JSON.decode(resp); + if (resp.status == "ko") { + current_anchor.get_callback("compose")(resp); + if ($defined(resp.respmsg)) { + infobox.error(resp.respmsg); + } + enable_link(this, sendmail_callback); + return; + } + current_anchor.parse_string(resp.url, true).setparams(navparams); + current_anchor.update(); + this.eliminate("editormode"); + }.bind(this) + }); + if (this.retrieve("editormode") == "html") { + CKEDITOR.instances[editorid].updateElement(); + } + $("composemail").send(); +} + /* * Callback of the 'compose' action */ @@ -108,33 +140,24 @@ function compose_callback(resp) { height: $(editorid).getSize().y }); } + if (resp.id) { + current_anchor.setparam("id", resp.id).update(0, 1); + } + $("attachments").addEvent("click", function(evt) { + SqueezeBox.open(this.get("name"), { + handler: "iframe", + size: {x: 350, y: 400}, + closeBtn: false + }); + }); + $$("a[name=back]") .removeEvents("click") .addEvent("click", loadFolder); $$("a[name=sendmail]") + .store("editormode", editormode) .removeEvents("click") - .addEvent("click", function(evt) { - evt.stop(); - $("composemail").set("send", { - onSuccess: function(resp) { - resp = JSON.decode(resp); - if (resp.status == "ko") { - current_anchor.get_callback("compose")(resp); - - if ($defined(resp.error)) { - infobox.error(resp.error); - } - return; - } - current_anchor.parse_string(resp.url, true).setparams(navparams); - current_anchor.update(); - } - }); - if (editormode == "html") { - CKEDITOR.instances[editorid].updateElement(); - } - $("composemail").send(); - }); + .addEvent("click", sendmail_callback); } /* diff --git a/extensions/webmail/templates/webmail/attachments.html b/extensions/webmail/templates/webmail/attachments.html new file mode 100644 index 000000000..d1660e605 --- /dev/null +++ b/extensions/webmail/templates/webmail/attachments.html @@ -0,0 +1,115 @@ +{% extends "layout_simple.html" %} +{% load i18n %} +{% block html_head %} + + + +{% endblock %} +{% block content %} +

{% trans "Attachments" %}

+
{{ error }}
+
+ {% include "common/genericform.html" %} +
+ +
+
+ + +
+{% for att in attachments %} +
+ + + + +
+{% endfor %} +
+
+ +
+{% endblock %} diff --git a/extensions/webmail/templates/webmail/compose.html b/extensions/webmail/templates/webmail/compose.html index b2988713d..424e41b97 100644 --- a/extensions/webmail/templates/webmail/compose.html +++ b/extensions/webmail/templates/webmail/compose.html @@ -3,6 +3,20 @@
{% include "common/genericform.html" %} +
+
+ + {% trans "Attachments" %} +
+
+ {% blocktrans count attachments|length as counter %} + {{ counter }} file + {% plural %} + {{ counter }} files + {% endblocktrans %} + {{ short_att_list }} +
+
{% if body %} diff --git a/extensions/webmail/templates/webmail/folders.html b/extensions/webmail/templates/webmail/folders.html index 78a838579..cf0b5610b 100644 --- a/extensions/webmail/templates/webmail/folders.html +++ b/extensions/webmail/templates/webmail/folders.html @@ -3,7 +3,7 @@ {% if titlebar %}
+ rel="{handler: 'iframe', size: {x: 350, y: 400}, closeBtn: true}"> {% trans 'Add a new folder' %} diff --git a/extensions/webmail/templates/webmail/upload_done.html b/extensions/webmail/templates/webmail/upload_done.html new file mode 100644 index 000000000..761c5d9ca --- /dev/null +++ b/extensions/webmail/templates/webmail/upload_done.html @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/extensions/webmail/urls.py b/extensions/webmail/urls.py index 201341160..e950fd222 100644 --- a/extensions/webmail/urls.py +++ b/extensions/webmail/urls.py @@ -1,5 +1,10 @@ from django.conf.urls.defaults import * +# +# FIXME: there is a big issue with this url mapping. If a folder's +# name matches one of the first entries (from "compose" to +# "attachments"), the associated folder will not be accessible. +# urlpatterns = patterns('modoboa.extensions.webmail.views', (r'^$', "index"), (r'^compose/$', "compose"), @@ -10,6 +15,10 @@ urlpatterns = patterns('modoboa.extensions.webmail.views', (r'^newfolder/$', "newfolder"), (r'^editfolder/$', 'editfolder'), (r'^delfolder/$', 'delfolder'), + + (r'^attachments/$', 'attachments'), + (r'^delattachment/$', 'delattachment'), + (r'^(?P.+)/(?P[\w\-\+]+)/part/$', 'getattachment'), (r'^(?P.+)/(?P[\w\-\+]+)/delete/$', 'delete'), (r'^(?P.+)/(?P[\w\-\+]+)/content/$', 'getmailcontent'), diff --git a/extensions/webmail/views.py b/extensions/webmail/views.py index e916375a3..1a76a3a9e 100644 --- a/extensions/webmail/views.py +++ b/extensions/webmail/views.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import time import sys -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, Http404, HttpResponseRedirect from django.template import Template, Context from django.template.loader import render_to_string from django.utils import simplejson @@ -21,13 +21,6 @@ from lib import * from forms import * from templatetags.webextras import * -def __get_current_url(request): - res = "%s?page=%s" % (request.session["folder"], request.session["page"]) - for p in ["criteria", "pattern", "order"]: - if p in request.session.keys(): - res += "&%s=%s" % (p, request.session[p]) - return res - def __render_common_components(request, folder_name, lst=None, content=None, menu=None): """Render all components that are common to all pages in the webmail @@ -153,7 +146,7 @@ def viewmail(request, folder, mail_id=None): content = Template(""" """).render(Context({"url" : url})) - menu = viewm_menu("", __get_current_url(request), folder, mail_id, + menu = viewm_menu("", get_current_url(request), folder, mail_id, request.user.get_all_permissions()) mbc = IMAPconnector(user=request.user.username, password=request.session["password"]) @@ -230,7 +223,7 @@ def delete(request, fdname, mail_id): mbc = IMAPconnector(user=request.user.username, password=request.session["password"]) mbc.move(mail_id, fdname, parameters.get_user(request.user, "TRASH_FOLDER")) - ctx = getctx("ok", next=__get_current_url(request)) + ctx = getctx("ok", next=get_current_url(request)) return HttpResponse(simplejson.dumps(ctx), mimetype="application/json") @login_required @@ -264,7 +257,7 @@ def compact(request, name): return folder(request, name, False) def render_compose(request, form, posturl, email=None, insert_signature=False): - menu = compose_menu("", __get_current_url(request), + menu = compose_menu("", get_current_url(request), request.user.get_all_permissions()) editor = parameters.get_user(request.user, "EDITOR") if email is None: @@ -276,121 +269,43 @@ def render_compose(request, form, posturl, email=None, insert_signature=False): if insert_signature: signature = EmailSignature(request.user) body += str(signature) + randid = None + if not request.GET.has_key("id"): + if request.session.has_key("compose_mail"): + clean_attachments(request.session["compose_mail"]["attachments"]) + randid = set_compose_session(request) + elif not request.session.has_key("compose_mail") \ + or request.session["compose_mail"]["id"] != request.GET["id"]: + randid = set_compose_session(request) + attachments = request.session["compose_mail"]["attachments"] + if len(attachments): + short_att_list = "(%s)" \ + % ", ".join(map(lambda att: att["fname"], + attachments[:2] + [{"fname" : "..."}] \ + if len(attachments) > 2 else attachments)) + else: + short_att_list = "" content = _render_to_string(request, "webmail/compose.html", { "form" : form, "bodyheader" : textheader, - "body" : body, "posturl" : posturl + "body" : body, "posturl" : posturl, + "attachments" : attachments, "short_att_list" : short_att_list }) mbc = IMAPconnector(user=request.user.username, password=request.session["password"]) ctx = getctx("ok", level=2, editor=editor, **__render_common_components(request, request.session["folder"], menu=menu, content=content)) + if randid is not None: + ctx["id"] = randid return HttpResponse(simplejson.dumps(ctx), mimetype="application/json") -def __html2plaintext(content): - """HTML to plain text translation - - :param content: some HTML content - """ - 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 send_mail(request, withctx=False, origmsg=None, posturl=None): - form = ComposeMailForm(request.POST) - error = None - ctx = None - editormode = parameters.get_user(request.user, "EDITOR") - if form.is_valid(): - from email.mime.text import MIMEText - from email.utils import make_msgid, formatdate - import smtplib - - body = request.POST["id_body"] - charset = "utf-8" - if editormode == "html": - from email.mime.multipart import MIMEMultipart - - msg = MIMEMultipart(_subtype="related") - submsg = MIMEMultipart(_subtype="alternative") - textbody = __html2plaintext(body) - 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) - for img in images: - msg.attach(img) - else: - msg = MIMEText(body.encode(charset), _subtype=editormode) - - msg["Subject"] = request.POST["subject"] - address, domain = split_mailbox(request.POST["from_"]) - try: - mb = Mailbox.objects.get(address=address, domain__name=domain) - msg["From"] = "%s <%s>" % (mb.name, request.POST["from_"]) - except Mailbox.DoesNotExist: - msg["From"] = request.POST["from_"] - msg["To"] = request.POST["to"] - msg["Message-ID"] = make_msgid() - msg["User-Agent"] = "Modoboa" - msg["Date"] = formatdate(time.time(), True) - if origmsg and origmsg.has_key("Message-ID"): - msg["References"] = msg["In-Reply-To"] = origmsg["Message-ID"] - rcpts = msg['To'].split(',') - if "cc" in request.POST.keys(): - msg["Cc"] = request.POST["cc"] - rcpts += msg["Cc"].split(",") - error = None - 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, text: - error = str(text) - if error is None: - if parameters.get_admin("SMTP_AUTHENTICATION") == "yes": - s.login(request.user.username, get_password(request)) - s.sendmail(msg['From'], rcpts, msg.as_string()) - s.quit() - sentfolder = parameters.get_user(request.user, "SENT_FOLDER") - IMAPconnector(user=request.user.username, - password=request.session["password"]).push_mail(sentfolder, msg) - ctx = getctx("ok", url=__get_current_url(request)) - - if ctx is None: - listing = render_to_string("webmail/compose.html", - {"form" : form, - "body" : request.POST["id_body"].strip(), - "posturl" : posturl}) - ctx = getctx("ko", level=2, error=error, listing=listing, editor=editormode) - if not withctx: - return HttpResponse(simplejson.dumps(ctx), mimetype="application/json") - return ctx, HttpResponse(simplejson.dumps(ctx), mimetype="application/json") - @login_required @is_not_localadmin() def compose(request): if request.method == "POST": - return send_mail(request, posturl=reverse(compose)) + status, resp = send_mail(request, posturl=reverse(compose)) + return resp form = ComposeMailForm() form.fields["from_"].initial = request.user.username @@ -401,13 +316,13 @@ def compose(request): def reply(request, folder, mail_id): msg = fetchmail(request, folder, mail_id, True) if request.method == "POST": - ctx, r = send_mail(request, True, origmsg=msg, - posturl=reverse(reply, args=[folder, mail_id])) - if ctx["status"] == "ok": + status, resp = send_mail(request, origmsg=msg, + posturl=reverse(reply, args=[folder, mail_id])) + if status: IMAPconnector(user=request.user.username, password=request.session["password"]).msg_answered(folder, mail_id) - return r + return resp form = ComposeMailForm() email = ReplyModifier(msg, request.user, form, request.GET.has_key("all"), @@ -419,9 +334,9 @@ def reply(request, folder, mail_id): @is_not_localadmin() def forward(request, folder, mail_id): if request.method == "POST": - ctx, response = send_mail(request, True, - posturl=reverse(forward, args=[folder, mail_id])) - if ctx["status"] == "ok": + status, response = send_mail(request, + posturl=reverse(forward, args=[folder, mail_id])) + if status: IMAPconnector(user=request.user.username, password=request.session["password"]).msgforwarded(folder, mail_id) @@ -523,3 +438,62 @@ def delfolder(request): password=request.session["password"]) mbc.delete_folder(request.GET["name"]) return ajax_response(request) + +@login_required +@is_not_localadmin() +def attachments(request, tplname="webmail/attachments.html"): + if request.method == "POST": + csuploader = AttachmentUploadHandler() + request.upload_handlers.insert(0, csuploader) + error = None + form = AttachmentForm(request.POST, request.FILES) + if form.is_valid(): + try: + fobj = request.FILES["attachment"] + tmpname = save_attachment(fobj) + request.session["compose_mail"]["attachments"] \ + += [{"fname" : str(fobj), + "content-type" : fobj.content_type, + "size" : fobj.size, + "tmpname" : os.path.basename(tmpname)}] + request.session.modified = True + return _render(request, "webmail/upload_done.html", { + "status" : "ok", "fname" : request.FILES["attachment"], + "tmpname" : os.path.basename(tmpname) + }); + except WebmailError, inst: + error = _("Failed to save attachment: ") + str(inst) + + if csuploader.toobig: + error = _("Attachment is too big (limit: %s)" \ + % parameters.get_admin("MAX_ATTACHMENT_SIZE")) + return _render(request, "webmail/upload_done.html", { + "status" : "ko", "error" : error + }); + ctx = {"form" : AttachmentForm(), + "attachments" : request.session["compose_mail"]["attachments"]} + return _render(request, tplname, ctx) + +@login_required +@is_not_localadmin() +def delattachment(request): + if not request.session.has_key("compose_mail") \ + or not request.GET.has_key("name") \ + or not request.GET["name"]: + return ajax_response(request, "ko", respmsg=_("Bad query")) + + error = None + for att in request.session["compose_mail"]["attachments"]: + if att["tmpname"] == request.GET["name"]: + request.session["compose_mail"]["attachments"].remove(att) + fullpath = os.path.join(settings.MEDIA_ROOT, "tmp", att["tmpname"]) + try: + os.remove(fullpath) + except OSError, e: + error = _("Failed to remove attachment: ") + str(e) + break + request.session.modified = True + return ajax_response(request) + if error is None: + error = _("Unknown attachment") + return ajax_response(request, "ko", respmsg=error) diff --git a/lib/parameters.py b/lib/parameters.py index 8134f6e85..a29986423 100644 --- a/lib/parameters.py +++ b/lib/parameters.py @@ -87,7 +87,7 @@ def save_admin(name, value, app=None): p = Parameter() p.name = fullname if p.value != value: - p.value = str(value).encode("string_escape") + p.value = str(value).encode("string_escape").strip() p.save() return True @@ -105,7 +105,7 @@ def save_user(user, name, value, app=None): p.user = user p.name = fullname if p.value != value: - p.value = str(value).encode("string_escape") + p.value = str(value).encode("string_escape").strip() p.save() return True diff --git a/lib/webutils.py b/lib/webutils.py index b91f8c9c2..c41b8d593 100644 --- a/lib/webutils.py +++ b/lib/webutils.py @@ -4,6 +4,7 @@ This module contains extra functions/shortcuts used to render HTML. """ import sys +import re from django.http import HttpResponse from django.shortcuts import render_to_response from django.template.loader import render_to_string @@ -115,3 +116,28 @@ def static_url(path): if path.startswith("/"): path = path[1:] return "%s%s" % (settings.MEDIA_URL, path) + +def size2integer(value): + """Try to convert a string representing a size to an integer value + in bytes. + + Supported formats: + * K|k for KB + * M|m for MB + * G|g for GB + + :param value: the string to convert + :return: the corresponding integer value + """ + m = re.match("(\d+)\s*(\w+)", value) + if m is None: + if re.match("\d+", value): + return int(value) + return 0 + if m.group(2)[0] in ["K", "k"]: + return int(m.group(1)) * 2 ** 10 + if m.group(2)[0] in ["M", "m"]: + return int(m.group(1)) * 2 ** 20 + if m.group(2)[0] in ["G", "g"]: + return int(m.group(1)) * 2 ** 30 + return 0 diff --git a/static/js/SqueezeBox.js b/static/js/SqueezeBox.js index b386bca6c..77cb7ac2c 100644 --- a/static/js/SqueezeBox.js +++ b/static/js/SqueezeBox.js @@ -88,7 +88,9 @@ var SqueezeBox = { } } this.content = new Element('div', {id: 'sbox-content'}).inject(this.win); - this.closeBtn = new Element('a', {id: 'sbox-btn-close', href: '#'}).inject(this.win); + if (this.options.closeBtn) { + this.closeBtn = new Element('a', {id: 'sbox-btn-close', href: '#'}).inject(this.win); + } this.fx = { overlay: new Fx.Tween(this.overlay, Object.merge({ property: 'opacity', @@ -155,6 +157,11 @@ var SqueezeBox = { this.overlay.set('class', this.options.classOverlay); this.win.set('class', this.options.classWindow); if (Browser.ie) this.win.addClass('sbox-window-ie6'); + if (!this.options.closeBtn) { + this.closeBtn.dispose(); + } else { + this.closeBtn.inject(this.win); + } }, close: function(e) { @@ -239,7 +246,9 @@ var SqueezeBox = { toggleListeners: function(state) { var fn = (state) ? 'addEvent' : 'removeEvent'; - this.closeBtn[fn]('click', this.bound.close); + if (this.options.closeBtn) { + this.closeBtn[fn]('click', this.bound.close); + } this.overlay[fn]('click', this.bound.close); this.doc[fn]('keydown', this.bound.key)[fn]('mousewheel', this.bound.scroll); this.doc.getWindow()[fn]('resize', this.bound.window)[fn]('scroll', this.bound.window); diff --git a/static/js/anchor_nav.js b/static/js/anchor_nav.js index c98624711..055896f50 100644 --- a/static/js/anchor_nav.js +++ b/static/js/anchor_nav.js @@ -20,6 +20,7 @@ var AnchorNavigation = new Class({ this.params = $H(); this.base = ''; this.serialized = null; + this.updatenext = true; this.reset_loading_infos(); if (this.options.defcallback) { this.register_callback("default", this.options.defcallback); @@ -83,11 +84,19 @@ var AnchorNavigation = new Class({ return res; }, - update: function(force) { + update: function(force, noupdate) { window.fireEvent("pageRefresh"); location.hash = this.serialize(); - if ($defined(force)) { - this.force = force; + + if (!$defined(noupdate)) { + noupdate = false; + } + if (!noupdate) { + if ($defined(force)) { + this.force = force; + } + } else { + this.updatenext = false; } }, @@ -183,6 +192,10 @@ var AnchorNavigation = new Class({ location.hash = this.deflocation; return; } + if (!this.updatenext) { + this.updatenext = true; + return; + } var query = this.serialized.substring(1); diff --git a/static/js/common.js b/static/js/common.js index b5c8214b3..7a5d462e9 100644 --- a/static/js/common.js +++ b/static/js/common.js @@ -26,6 +26,28 @@ function static_url(value) { return media_url + value; } +/* + * Easy way to deactivate a link (ie. clicking on it does nothing) + */ +function disable_link(link) { + link.removeEvents("click") + .addEvent("click", function(evt) { + evt.stop(); + }); +} + +/* + * Easy way to re-activate a link that was previously disabled. An + * extra argument can be passed to specify a new "click" event + * callback. + */ +function enable_link(link, callback) { + link.removeEvents("click"); + if ($defined(callback)) { + link.addEvent("click", callback); + } +} + function parse_menubar(id) { if ($(id)) { $(id).getElements('li.dropdown').each(function(elem){ diff --git a/static/pics/attachment.png b/static/pics/attachment.png new file mode 100644 index 0000000000000000000000000000000000000000..529bb7f554dfa585a4a8d6b9135956b188be3f37 GIT binary patch literal 649 zcmV;40(Sk0P)s%e4^YA&(d3cVhTCLK&Bw17g6TmTG z1V{n*fT@5#xT4HeH3O0?b^tS7rmoSw{r%V-b~ZOQiN#_pEk6BTsZ=}xe=t$cSGQab zmz{mi(ebe{R#sQJ6}U|#@&J>`gv;$R0T-3Ye*j4q&q%WP893+j`Iw##vHWIvYHO=7 zl>V7MAB{f7>-7Sh`X>Nf@%e^rz^+b1Cz(tJ;8MUJTm){cudQL&u@hi-JD_9GGYEiI z(AHxWNfwU+*8l`T2hh+oL9th9b-HH241~;Pvnr8Dpb>-?Kv{uGC;SGqx_WF6pgJv| z1wdDW+XMBRg})V4l@)2!{{iYJm2wreS`E+xP%4!GD3*%b1NmGYfLE_yGT`11Fln=P z^YPOM0P?w93((NSLZJrC%||(L{3KS3&1kV$2}foCC=?3Y0_)jq)}YtxNhW_}CU0Ih z_SzjhiasQ}k;T#LB>pW95Nr2@*@dMACdQA@(``3uG#cK$e@nSi#bYy1oMBO%h6bQrj!1R8#$B#Q=Wvpxb0Q=5Y{ jFbmuT!U2DDE_ literal 0 HcmV?d00001 diff --git a/templates/common/genericform.html b/templates/common/genericform.html index 0d349f9ad..3ebfbb087 100644 --- a/templates/common/genericform.html +++ b/templates/common/genericform.html @@ -1,12 +1,12 @@ {% load i18n %} {% for field in form %} -
+
{% if field.help_text %}
{% trans "Help" %}
{{ field.help_text}}
{% endif %} - {{ field.label_tag }} {{ field }} {% ifequal noerrors False %}{{ field.errors }}{% endifequal %} + {{ field.label_tag }}
{{ field }}
{% ifequal noerrors False %}{{ field.errors }}{% endifequal %}
{% endfor %} diff --git a/userprefs/templatetags/uprefs_extras.py b/userprefs/templatetags/uprefs_extras.py index 3c34858a3..cc79d34c0 100644 --- a/userprefs/templatetags/uprefs_extras.py +++ b/userprefs/templatetags/uprefs_extras.py @@ -22,7 +22,7 @@ def options_menu(user): "img" : static_url("pics/edit.png"), "label" : _("Change password"), "class" : "boxed", - "rel" : "{handler:'iframe',size:{x:360,y:200}}"}, + "rel" : "{handler:'iframe',size:{x:360,y:200},closeBtn:true}"}, {"name" : "preferences", "img" : static_url("pics/user.png"), "label" : _("Preferences"), @@ -38,7 +38,7 @@ def options_menu(user): "img" : static_url("pics/alias.png"), "label" : _("Forward"), "class" : "boxed", - "rel" : "{handler:'iframe',size:{x:360,y:320}}" + "rel" : "{handler:'iframe',size:{x:360,y:320},closeBtn:true}" }] entries[0]["menu"] += events.raiseQueryEvent("UserMenuDisplay",