From 2cf2a68bd1c25d8fe4f3126f40bd57982cc6b2a4 Mon Sep 17 00:00:00 2001 From: "Jannis M. Hoffmann" Date: Sun, 3 Dec 2023 19:22:12 +0100 Subject: initial commit --- src/jwebmail/__init__.py | 91 ++++++ src/jwebmail/css.py | 17 ++ src/jwebmail/model/read_mails.py | 128 ++++++++ src/jwebmail/read_mails.py | 50 ++++ src/jwebmail/render_mail.py | 160 ++++++++++ src/jwebmail/static/src/displayheaders.js | 35 +++ src/jwebmail/static/src/rendermail.js | 21 ++ src/jwebmail/templates/_bot_nav.html | 48 +++ src/jwebmail/templates/_folders.html | 48 +++ src/jwebmail/templates/_main_table.html | 37 +++ src/jwebmail/templates/_top_nav.html | 48 +++ src/jwebmail/templates/about.html | 62 ++++ src/jwebmail/templates/displayheaders.html | 32 ++ src/jwebmail/templates/exception_.html | 30 ++ src/jwebmail/templates/login.html | 83 ++++++ src/jwebmail/templates/mainlayout.html | 34 +++ src/jwebmail/templates/not_found.html | 25 ++ src/jwebmail/templates/readmail.html | 17 ++ src/jwebmail/templates/writemail.html | 82 ++++++ .../translations/de/LC_MESSAGES/messages.po | 167 +++++++++++ src/jwebmail/view.py | 46 +++ src/jwebmail/webmail.py | 321 +++++++++++++++++++++ 22 files changed, 1582 insertions(+) create mode 100644 src/jwebmail/__init__.py create mode 100644 src/jwebmail/css.py create mode 100644 src/jwebmail/model/read_mails.py create mode 100644 src/jwebmail/read_mails.py create mode 100644 src/jwebmail/render_mail.py create mode 100644 src/jwebmail/static/src/displayheaders.js create mode 100644 src/jwebmail/static/src/rendermail.js create mode 100644 src/jwebmail/templates/_bot_nav.html create mode 100644 src/jwebmail/templates/_folders.html create mode 100644 src/jwebmail/templates/_main_table.html create mode 100644 src/jwebmail/templates/_top_nav.html create mode 100644 src/jwebmail/templates/about.html create mode 100644 src/jwebmail/templates/displayheaders.html create mode 100644 src/jwebmail/templates/exception_.html create mode 100644 src/jwebmail/templates/login.html create mode 100644 src/jwebmail/templates/mainlayout.html create mode 100644 src/jwebmail/templates/not_found.html create mode 100644 src/jwebmail/templates/readmail.html create mode 100644 src/jwebmail/templates/writemail.html create mode 100644 src/jwebmail/translations/de/LC_MESSAGES/messages.po create mode 100644 src/jwebmail/view.py create mode 100644 src/jwebmail/webmail.py (limited to 'src/jwebmail') diff --git a/src/jwebmail/__init__.py b/src/jwebmail/__init__.py new file mode 100644 index 0000000..db0c796 --- /dev/null +++ b/src/jwebmail/__init__.py @@ -0,0 +1,91 @@ +import os.path as ospath +import pwd +import sys + +from flask import Flask +from flask_babel import Babel, get_locale +from flask_login import LoginManager, login_required +from jinja2 import ChainableUndefined + +from .css import compile_css_command +from .read_mails import load_user +from .render_mail import format_mail +from .view import add_view_funcs +from .webmail import ( + about, + displayheaders, + login, + logout, + move, + rawmail, + readmail, + sendmail, + writemail, +) + +if sys.version_info >= (3, 11): + from tomllib import load as toml_load +else: + from toml import load as toml_load + +__version__ = "2.0.0" + + +def validate_config(app): + conf = app.config + + assert "@" in conf["JWEBMAIL"]["ADMIN_MAIL"] + + assert pwd.getpwnam(conf["JWEBMAIL"]["READ_MAILS"]["MAILBOX_USER"]) + assert ospath.isdir(conf["JWEBMAIL"]["READ_MAILS"]["MAILBOX"]) + assert ospath.isfile(conf["JWEBMAIL"]["READ_MAILS"]["AUTHENTICATOR"]) + assert ospath.isfile(conf["JWEBMAIL"]["READ_MAILS"]["BACKEND"]) + + +def create_app(): + app = Flask(__name__) + app.jinja_options = dict(undefined=ChainableUndefined) + + app.config.from_file("../../jwebmail.toml", load=toml_load, text=False) + validate_config(app) + + Babel(app, locale_selector=lambda: "de") + + app.cli.add_command(compile_css_command) + + login_manager = LoginManager() + login_manager.login_view = "login" + login_manager.user_loader(load_user) + login_manager.init_app(app) + + @app.context_processor + def inject_version(): + return {"version": "4.0", "get_locale": get_locale, "format_mail": format_mail} + + add_view_funcs(app) + route(app) + + return app + + +def route(app): + app.add_url_rule("/", view_func=login, methods=["GET", "POST"]) + + app.add_url_rule("/about", view_func=about) + app.add_url_rule("/logout", view_func=logout) + + dh = login_required(displayheaders) + app.add_url_rule("/home/", view_func=dh) + app.add_url_rule("/home/", view_func=dh) + + app.add_url_rule( + "/read/", endpoint="read", view_func=login_required(readmail) + ) + app.add_url_rule("/raw/", endpoint="raw", view_func=login_required(rawmail)) + + app.add_url_rule("/write", endpoint="write", view_func=login_required(writemail)) + app.add_url_rule( + "/write", endpoint="send", view_func=login_required(sendmail), methods=["POST"] + ) + + app.add_url_rule("/move/", view_func=login_required(move), methods=["POST"]) diff --git a/src/jwebmail/css.py b/src/jwebmail/css.py new file mode 100644 index 0000000..24c5239 --- /dev/null +++ b/src/jwebmail/css.py @@ -0,0 +1,17 @@ +import subprocess + +import click + + +@click.command("compile-css") +def compile_css_command(): + subprocess.run( + [ + "node_modules/.bin/sass", + "--load-path=node_modules/", + "scss/my_bulma.scss", + "src/jwebmail/static/css/my_bulma.css", + ], + check=True, + ) + click.echo("Done compiling.") diff --git a/src/jwebmail/model/read_mails.py b/src/jwebmail/model/read_mails.py new file mode 100644 index 0000000..f82b601 --- /dev/null +++ b/src/jwebmail/model/read_mails.py @@ -0,0 +1,128 @@ +import json +import shlex +from subprocess import PIPE, Popen, TimeoutExpired +from subprocess import run as subprocess_run + + +class QMAuthError(Exception): + def __init__(self, msg, rc, response=None): + super().__init__(msg, rc, response) + self.msg = msg + self.rc = rc + self.response = response + + +class QMailAuthuser: + def __init__( + self, username, password, prog, mailbox_path, virtual_user, authenticator + ): + self._username = username + self._password = password + self._prog = prog + self._mailbox_path = mailbox_path + self._virtual_user = virtual_user + self._authenticator = authenticator + + def verify_user(self): + try: + completed_proc = subprocess_run( + f"{self._authenticator} true 3<&0", + input=f"{self._username}\0{self._password}\0\0".encode(), + shell=True, + timeout=2, + ) + match completed_proc.returncode: + case 0: + return True + case 1: + return False + case n: + raise QMAuthError("authentication error", n) + except TimeoutExpired: + return False + + def read_headers_for(self, folder, start, end, sort): + return self.build_and_run("list", [folder, start, end, sort]) + + def count(self, folder): + return self.build_and_run("count", [folder]) + + def show(self, folder, msgid): + return self.build_and_run("read", [folder, msgid]) + + def raw(self, folder, mid, path): + return self.build_and_run("raw", [folder, mid, path]) + + def search(self, pattern, folder): + return self.build_and_run("search", [pattern, folder]) + + def folders(self): + res = self.build_and_run("folders") + if isinstance(res, list): + return [""] + res + return res + + def move(self, mid, from_f, to_f): + _resp = self.build_and_run("move", [mid, from_f, to_f]) + return True + + def remove(self, folder, msgid): + _resp = self.build_and_run("remove", [folder, msgid]) + return True + + def _build_arg(self, user_mail_addr, mode, args): + idx = user_mail_addr.find("@") + user_name = user_mail_addr[:idx] + + return ( + " ".join( + shlex.quote(str(x)) + for x in [ + self._authenticator, + self._prog, + self._mailbox_path, + self._virtual_user, + user_name, + mode, + *args, + ] + ) + + " 3<&0" + ) + + def _read_qmauth(self, prog_and_args): + popen = Popen(prog_and_args, stdin=PIPE, stdout=PIPE, shell=True) + + try: + inp, _ = popen.communicate( + f"{self._username}\0{self._password}\0\0".encode(), timeout=30 + ) + popen.wait(1) + except TimeoutExpired: + popen.terminate() + + if popen.poll() is None: + popen.kill() + popen.poll() + + rc = popen.returncode + if rc in [0, 3]: + try: + a, b, c = inp.partition(b"\n") + d = json.loads(a) + if b: + resp = dict(head=d, body=c.decode()) + else: + resp = d + except json.JSONDecodeError: + raise QMAuthError("error decoding response", rc, inp) + if rc == 3: + raise QMAuthError("error reported by extractor", rc, resp) + else: + raise QMAuthError("got unsuccessful return code by qmail-authuser", rc, inp) + + return resp + + def build_and_run(self, mode, args=()): + prog_and_args = self._build_arg(self._username, mode, args) + return self._read_qmauth(prog_and_args) diff --git a/src/jwebmail/read_mails.py b/src/jwebmail/read_mails.py new file mode 100644 index 0000000..e1c3b8c --- /dev/null +++ b/src/jwebmail/read_mails.py @@ -0,0 +1,50 @@ +import dbm +import shelve + +from flask import current_app, g +from flask_login import current_user + +from .model.read_mails import QMailAuthuser + + +def build_qma(username, password): + authenticator = current_app.config["JWEBMAIL"]["READ_MAILS"]["AUTHENTICATOR"] + backend = current_app.config["JWEBMAIL"]["READ_MAILS"]["BACKEND"] + mailbox = current_app.config["JWEBMAIL"]["READ_MAILS"]["MAILBOX"] + mailbox_user = current_app.config["JWEBMAIL"]["READ_MAILS"]["MAILBOX_USER"] + + return QMailAuthuser( + username, password, backend, mailbox, mailbox_user, authenticator + ) + + +def login(username, password): + return build_qma(username, password).verify_user() + + +def add_user(user): + with shelve.open("user_sessions", flag="c") as s: + s[user.get_id()] = user + + +def load_user(username): + try: + with shelve.open("user_sessions", flag="r") as s: + user = s[username] + return user + except dbm.error: + return None + except KeyError: + return None + + +def get_read_mails_logged_in(): + if "read_mails" in g: + return g.read_mails + + with shelve.open("user_sessions", flag="r") as s: + user_data = s[current_user.get_id()] + + qma = build_qma(current_user.get_id(), user_data.password) + g.read_mails = qma + return qma diff --git a/src/jwebmail/render_mail.py b/src/jwebmail/render_mail.py new file mode 100644 index 0000000..0e5406c --- /dev/null +++ b/src/jwebmail/render_mail.py @@ -0,0 +1,160 @@ +from flask import current_app, request, url_for +from flask_babel import gettext +from markupsafe import Markup, escape + + +def render_text_plain(_subtype, content, _path): + return f'
{escape(content)}
\n' + + +def render_text_html(_subtype, _content, path): + if path: + url = url_for( + "raw", msgid=request.view_args["msgid"], path=".".join(map(str, path)) + ) + else: + url = url_for("raw", msgid=request.view_args["msgid"]) + + return f'\n' + + +def render_image(subtype, content, _path): + return f'\n' + + +def render_multipart_alternative(_subtype, content, path): + parts = content["parts"] + T = "
    \n" + C = "
    " + init, *rest = reversed(parts) + + T += f"
  • {to_mime_type(init['head'])}
  • " + + C += "
    \n" + C += mime_render( + *to_mime_types(init["head"]), init["body"], path + (len(parts) - 1,) + ) + C += "
    \n" + + for i, r in enumerate(rest, 1): + T += f"
  • {to_mime_type(r['head'])}
  • \n" + + C += '\n" + + C += "
    " + T += "
" + script_url = url_for("static", filename="src/rendermail.js") + return f'
\n{T}\n{C}\n
\n' + + +def render_multipart(_subtype, content, path): + parts = content["parts"] + R = '
\n' + + for i, p in enumerate(parts): + R += "
\n" + if ( + not p["head"]["content_disposition"] + or p["head"]["content_disposition"].lower() == "none" + or p["head"]["content_disposition"].lower() == "inline" + ): + R += mime_render(*to_mime_types(p["head"]), p["body"], path + (i,)) + elif p["head"]["content_disposition"].lower() == "attachment": + link_text = gettext("Attachment {filename} of type {filetype}").format( + filename=p["head"]["filename"], filetype=to_mime_type(p["head"]) + ) + + ref_url = url_for( + "raw", + msgid=request.view_args["msgid"], + path=".".join(map(str, [*path, i])), + ) + + R += "

" + R += f'\n' + R += f"{escape(link_text)}\n" + R += "

\n" + else: + current_app.log.warning( + "unknown Content-Disposition %s", p["head"]["content_disposition"] + ) + R += f"

unknown Content-Disposition {p['head']['content_disposition']}

\n" + + R += "
\n" + + return R + "
\n" + + +def _format_header(category, value): + R = "" + + if isinstance(value, list) and value: + R += f"
{escape(category)}
\n" + for v in value: + value = ( + f'"{v["display_name"]}" <{v["address"]}>' + if v["display_name"] + else v["address"] + ) + R += f"
{escape(value)}
\n" + + return R + + +def render_message(subtype, msg, path): + if subtype != "rfc822": + current_app.log.warning("unknown message mime-subtype %s", subtype) + + R = '
' + + R += '
' + R += f"
{escape(gettext('Subject'))}
" + R += f"
{escape(msg['head']['subject'])}
\n" + R += _format_header(gettext("From"), msg["head"]["from"]) + R += _format_header(gettext("To"), msg["head"]["to"]) + R += _format_header(gettext("CC"), msg["head"]["cc"]) + R += _format_header(gettext("BCC"), msg["head"]["bcc"]) + R += f"
{escape(gettext('Date'))}
" + R += f"
{escape(msg['head']['date'])}
\n" + R += f"
{escape(gettext('Content-Type'))}
" + R += f"
{to_mime_type(msg['head']['mime'])}
\n" + R += "
\n" + + R += mime_render(*to_mime_types(msg["head"]["mime"]), msg["body"], path + (0,)) + + return R + "
\n" + + +MIMERenderSubs = { + ("text", "plain"): render_text_plain, + ("text", "html"): render_text_html, + ("multipart", "alternative"): render_multipart_alternative, + "multipart": render_multipart, + "message": render_message, + "image": render_image, +} + + +def mime_render(maintype, subtype, content, path): + renderer = MIMERenderSubs.get((maintype, subtype)) or MIMERenderSubs.get(maintype) + + if not renderer: + return f'

Unsupported MIME type of {maintype}/{subtype}.

\n' + + return renderer(subtype, content, path) + + +def to_mime_type(mime): + return escape(f"{mime['content_maintype']}/{mime['content_subtype']}".lower()) + + +def to_mime_types(mime): + return escape(mime["content_maintype"]), escape(mime["content_subtype"]) + + +def format_mail(mail): + return Markup(mime_render("message", "rfc822", mail, tuple())) diff --git a/src/jwebmail/static/src/displayheaders.js b/src/jwebmail/static/src/displayheaders.js new file mode 100644 index 0000000..3c0936a --- /dev/null +++ b/src/jwebmail/static/src/displayheaders.js @@ -0,0 +1,35 @@ +function toggle_navbar() { + // Get the target from the "data-target" attribute + const target = this.dataset.target; + const $target = document.getElementById(target); + + // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" + this.classList.toggle('is-active'); + $target.classList.toggle('is-active'); +} + +function sort_select_submit() { + this.children[0].form.submit(); +} + +function check_all() { + const setTo = this.checked; + const chkbox = document.getElementsByClassName('jwm-mail-checkbox'); + + for (const m of chkbox) + m.checked = setTo; +} + +document.addEventListener("DOMContentLoaded", function() { + { + const sort_select = document.getElementById("sort"); + const current_option_name = new URL(document.location).searchParams.get("sort"); + if (current_option_name) + sort_select.value = current_option_name; + } + + document.getElementById("sort-select").addEventListener("change", sort_select_submit); + document.getElementById("navbar-toggle").addEventListener("click", toggle_navbar); + document.getElementById("check-all").addEventListener("click", check_all); +}); + diff --git a/src/jwebmail/static/src/rendermail.js b/src/jwebmail/static/src/rendermail.js new file mode 100644 index 0000000..1331913 --- /dev/null +++ b/src/jwebmail/static/src/rendermail.js @@ -0,0 +1,21 @@ +function tabSelection(evt) { + const self = evt.target; + + for (const ts of self.parentElement.parentElement.children) { + ts.classList.remove('is-active'); + } + self.parentNode.classList.add('is-active'); + + const bodies = self.parentElement.parentElement.parentElement.parentElement.children[1].children; + for (const ts of bodies) { + ts.classList.add('is-hidden'); + } + bodies[+self.parentElement.attributes.data.value].classList.remove('is-hidden'); +} + +document.addEventListener("DOMContentLoaded", function() { + const tabSections = document.getElementsByClassName("jwm-mail-body-multipart-alternative"); + for (const ts of tabSections) { + Array.from(ts.children[0].children[0].children).forEach(element => element.children[0].addEventListener('click', tabSelection)); + } +}) diff --git a/src/jwebmail/templates/_bot_nav.html b/src/jwebmail/templates/_bot_nav.html new file mode 100644 index 0000000..e54fd4c --- /dev/null +++ b/src/jwebmail/templates/_bot_nav.html @@ -0,0 +1,48 @@ +
+ +
+ {{ pgn.links }} +
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+ {# csrf_field #} +
+ +
+
+
+
+
+
+ +
+
+ {# csrf_field #} +
+ +
+
+
+ +
+ + +
+ +
diff --git a/src/jwebmail/templates/_folders.html b/src/jwebmail/templates/_folders.html new file mode 100644 index 0000000..fd62ab2 --- /dev/null +++ b/src/jwebmail/templates/_folders.html @@ -0,0 +1,48 @@ +
+ +
+ +
+ +
+
+ + {{ pgn.info }} + + + {% if total_new_mails %} + {% trans %}{{ total_new_mails }} new{% endtrans %} + {% endif %} + + + {% if total_size %} + {% trans %}mailbox size: {% endtrans %} + {{ total_size|byte_size10 }} + {% endif %} + +
+
+ +
diff --git a/src/jwebmail/templates/_main_table.html b/src/jwebmail/templates/_main_table.html new file mode 100644 index 0000000..02f9f81 --- /dev/null +++ b/src/jwebmail/templates/_main_table.html @@ -0,0 +1,37 @@ +
+ + {% for msg in msgs %} + + +
+ {{ loop.index + (pgn.page - 1) * pgn.per_page }} +
+ +
+
+
+ {{ msg.head.sender.0.display_name or msg.head.sender.0.address or msg.head.from.0.display_name or msg.head.from.0.address }} +
+ +
+ {{ parse_iso_date(msg.head.date)|datetimeformat }} +
+ + + +
+ {{ msg.byte_size|byte_size10 }} +
+
+
+ +
+ +
+
+ + {% endfor %} + +
diff --git a/src/jwebmail/templates/_top_nav.html b/src/jwebmail/templates/_top_nav.html new file mode 100644 index 0000000..bc4afa5 --- /dev/null +++ b/src/jwebmail/templates/_top_nav.html @@ -0,0 +1,48 @@ +
+ + + +
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+ {{ pgn.links }} +
+ +
diff --git a/src/jwebmail/templates/about.html b/src/jwebmail/templates/about.html new file mode 100644 index 0000000..e197275 --- /dev/null +++ b/src/jwebmail/templates/about.html @@ -0,0 +1,62 @@ +{% extends 'mainlayout.html' %} + +{% block content %} +
+ +
+ +

About JWebmail {{ version }}

+ +

+ JWebmail {{ version }} is a Webmail solution meant to be used with + s/qmail +

+ +

Features

+
    + +
  • multiple language support
  • +
  • session management
  • +
  • search for mails
  • +
  • CGI support but also psgi/plack and fcgi
  • +
+ +

+ This is a + GPL + licensed project, created by Oliver 'omnis' Müller + and currently maintained by + Jannis M. Hoffmann +

+ +

Supported languages

+
    +{% for lang in languages %} +
  • + {{ get_locale().languages[lang.language] }} +
  • +{% endfor %} +
+ +

+ JWebmail is programmed in Perl, and is + a complete rewrite of oMail-webmail. +

+ +
+ + + +
+{% endblock %} diff --git a/src/jwebmail/templates/displayheaders.html b/src/jwebmail/templates/displayheaders.html new file mode 100644 index 0000000..ce9ea6e --- /dev/null +++ b/src/jwebmail/templates/displayheaders.html @@ -0,0 +1,32 @@ +{% extends 'mainlayout.html' %} + +{% block scripts %} + +{% endblock %} + +{% block content %} +
+ + {% include '_folders.html' %} + + {% if loginmessage is defined %} +

+ {{ loginmessage }} +

+ {% endif %} + + {% include '_top_nav.html' %} + + {% if msgs %} + {% include '_main_table.html' %} + {% else %} +

+ {% trans %}This folder is empty!{% endtrans %} +

+ {% endif %} + + {% include '_bot_nav.html' %} + +
+{% endblock %} diff --git a/src/jwebmail/templates/exception_.html b/src/jwebmail/templates/exception_.html new file mode 100644 index 0000000..0b093b1 --- /dev/null +++ b/src/jwebmail/templates/exception_.html @@ -0,0 +1,30 @@ + + + + + + Error + + + +

Error

+

+ {% if error is defined %} + {{ error }} + {% else %} + Uwu :( + {% endif %} +

+ + {% if links is defined %} + See: + + {% endif %} + + + diff --git a/src/jwebmail/templates/login.html b/src/jwebmail/templates/login.html new file mode 100644 index 0000000..2915de3 --- /dev/null +++ b/src/jwebmail/templates/login.html @@ -0,0 +1,83 @@ +{% extends 'mainlayout.html' %} + +{% block content %} +
+
+ +

+ {% trans %}Login{% endtrans %} +

+

+ JWebmail +

+ +{% if login_form.errors %} +
+
+ {{ login_form.errors }} +
+
+{% endif %} +{% if warn %} +
+
+ {{ warn }} +
+
+{% endif %} + +
+ + {{ login_form.csrf_token }} + +
+
+ {{ login_form.username.label(class='label') }} +
+
+
+
+ {{ login_form.username(class='input') }} +
+
+
+
+ +
+
+ {{ login_form.password.label(class='label') }} +
+
+
+
+ {{ login_form.password(class='input') }} +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/src/jwebmail/templates/mainlayout.html b/src/jwebmail/templates/mainlayout.html new file mode 100644 index 0000000..46a07af --- /dev/null +++ b/src/jwebmail/templates/mainlayout.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + {{ title or 'JWebmail' }} + + + + + {% block content required %}{% endblock %} + + + + {% block scripts %}{% endblock %} + + + + diff --git a/src/jwebmail/templates/not_found.html b/src/jwebmail/templates/not_found.html new file mode 100644 index 0000000..1dc8f0c --- /dev/null +++ b/src/jwebmail/templates/not_found.html @@ -0,0 +1,25 @@ + + + + + + + Not Found + + + + + +
+
+

+ Not the page you are looking for. +

+

+ Go back or go to the {% trans %}start page{% endtrans %}. +

+
+
+ + + diff --git a/src/jwebmail/templates/readmail.html b/src/jwebmail/templates/readmail.html new file mode 100644 index 0000000..f41197a --- /dev/null +++ b/src/jwebmail/templates/readmail.html @@ -0,0 +1,17 @@ +{% extends 'mainlayout.html' %} + +{% block content %} +
+ +

Read Mail

+ + {{ format_mail(msg) }} + + + +
+{% endblock %} diff --git a/src/jwebmail/templates/writemail.html b/src/jwebmail/templates/writemail.html new file mode 100644 index 0000000..31adff1 --- /dev/null +++ b/src/jwebmail/templates/writemail.html @@ -0,0 +1,82 @@ +{% extends 'mainlayout.html' %} + +{% block content %} +
+ +

Write Message

+ + {% if warning is defined %} +

{{ warning }}

+ {% endif %} + +
+ +
+ {{ form.send_to.label(class='label') }} +
+ {{ form.send_to(class='input') }} +
+
+ +
+ {{ form.subject.label(class='label') }} +
+ {{ form.subject(class='input') }} +
+
+ +
+ {{ form.cc.label(class='label') }} +
+ {{ form.cc(class='input') }} +
+
+ +
+ {{ form.bcc.label(class='label') }} +
+ {{ form.bcc(class='input') }} +
+
+ +
+ {{ form.answer_to.label(class='label') }} +
+ {{ form.answer_to(class='input') }} +
+
+ +
+ {{ form.content.label(class='label') }} +
+ {{ form.content(class='textarea', rows=10) }} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + +
+{% endblock %} diff --git a/src/jwebmail/translations/de/LC_MESSAGES/messages.po b/src/jwebmail/translations/de/LC_MESSAGES/messages.po new file mode 100644 index 0000000..ad48d16 --- /dev/null +++ b/src/jwebmail/translations/de/LC_MESSAGES/messages.po @@ -0,0 +1,167 @@ +# German translations for JWebmail. +# Copyright (C) 2023 Fehcom +# This file is distributed under the same license as the PROJECT project. +# Jannis M. Hoffmann , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2023-11-26 19:21+0100\n" +"PO-Revision-Date: 2023-11-23 12:18+0100\n" +"Last-Translator: FULL NAME \n" +"Language: de\n" +"Language-Team: de \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.13.1\n" + +#: jwebmail/render_mail.py:63 +msgid "Attachment {filename} of type {filetype}" +msgstr "Anhang {filename} des types {filetype}" + +#: jwebmail/render_mail.py:113 +msgid "From" +msgstr "Von" + +#: jwebmail/render_mail.py:114 +msgid "To" +msgstr "Fuer" + +#: jwebmail/render_mail.py:115 +msgid "CC" +msgstr "" + +#: jwebmail/render_mail.py:116 +msgid "BCC" +msgstr "" + +#: jwebmail/webmail.py:39 +msgid "Username" +msgstr "Nutzername" + +#: jwebmail/webmail.py:41 +msgid "Password" +msgstr "Passwort" + +#: jwebmail/webmail.py:75 +msgid "login failed" +msgstr "Login fehlgeschlagen" + +#: jwebmail/webmail.py:120 +msgid "displaying {start} - {end} of {total} {record_name}" +msgstr "zeige {start} - {end} von {total} {record_name} an" + +#: jwebmail/webmail.py:173 +msgid "succ_move" +msgstr "erfolgreich Verschoben" + +#: jwebmail/webmail.py:221 +msgid "error_send" +msgstr "Fehler beim senden" + +#: jwebmail/webmail.py:223 +msgid "succ_send" +msgstr "erfolgreich Verschoben" + +#: jwebmail/templates/_bot_nav.html:11 +msgid "Move to" +msgstr "Verschiebe nach" + +#: jwebmail/templates/_bot_nav.html:19 jwebmail/templates/_folders.html:21 +msgid "Home" +msgstr "Ursprung" + +#: jwebmail/templates/_bot_nav.html:26 +msgid "Move" +msgstr "Verschieben" + +#: jwebmail/templates/_bot_nav.html:38 +msgid "Remove" +msgstr "Loeschen" + +#: jwebmail/templates/_bot_nav.html:44 +msgid "check all" +msgstr "alle markieren" + +#: jwebmail/templates/_folders.html:36 +#, python-format +msgid "%(total_new_mails)s new" +msgstr "%(total_new_mails)s neu" + +#: jwebmail/templates/_folders.html:41 +msgid "mailbox size: " +msgstr "mailbox groesse: " + +#: jwebmail/templates/_top_nav.html:4 +msgid "Logout" +msgstr "Abmelden" + +#: jwebmail/templates/_top_nav.html:5 +msgid "Write" +msgstr "Schreiben" + +#: jwebmail/templates/_top_nav.html:11 +msgid "Search" +msgstr "Suchen" + +#: jwebmail/templates/_top_nav.html:26 +msgid "Sort" +msgstr "Sortieren" + +#: jwebmail/templates/_top_nav.html:32 jwebmail/templates/_top_nav.html:33 +msgid "Date" +msgstr "Datum" + +#: jwebmail/templates/_top_nav.html:32 jwebmail/templates/_top_nav.html:34 +#: jwebmail/templates/_top_nav.html:35 +msgid "Descending" +msgstr "Absteigend" + +#: jwebmail/templates/_top_nav.html:33 jwebmail/templates/_top_nav.html:36 +msgid "Ascending" +msgstr "Aufsteigend" + +#: jwebmail/templates/_top_nav.html:34 +msgid "Size" +msgstr "Groesse" + +#: jwebmail/templates/_top_nav.html:35 jwebmail/templates/_top_nav.html:36 +msgid "Sender" +msgstr "Sender" + +#: jwebmail/templates/about.html:57 jwebmail/templates/login.html:13 +#: jwebmail/templates/login.html:69 +msgid "Login" +msgstr "Anmelden" + +#: jwebmail/templates/displayheaders.html:25 +msgid "This folder is empty!" +msgstr "Dieses Verzeichnis ist leer!" + +#: jwebmail/templates/mainlayout.html:22 +msgid "About" +msgstr "Ueber" + +#: jwebmail/templates/mainlayout.html:25 +msgid "Version" +msgstr "Version" + +#: jwebmail/templates/not_found.html:19 +msgid "start page" +msgstr "Startseite" + +#: jwebmail/templates/readmail.html:12 jwebmail/templates/writemail.html:78 +msgid "back" +msgstr "zurueck" + +#: jwebmail/templates/writemail.html:62 +msgid "attach file" +msgstr "Datei anhaengen" + +#: jwebmail/templates/writemail.html:71 +msgid "Send" +msgstr "Senden" + diff --git a/src/jwebmail/view.py b/src/jwebmail/view.py new file mode 100644 index 0000000..7983c81 --- /dev/null +++ b/src/jwebmail/view.py @@ -0,0 +1,46 @@ +from datetime import datetime +from math import floor, log2, log10 + +from markupsafe import Markup + + +def print_sizes10(var): + i = floor(log10(var) / 3) + expo = i * 3 + + PREFIX = [ + "Byte", + "kByte", + "MByte", + "GByte", + "TByte", + "PByte", + ] + + return Markup("{} {}".format(round(var / (10**expo)), PREFIX[i])) + + +def print_sizes2(var): + i = floor(log2(var) / 10) + expo = i * 10 + + PREFIX = [ + "Byte", + "KiByte", + "MiByte", + "GiByte", + "TiByte", + "PiByte", + ] + + return Markup("{} {}".format(round(var / (2**expo)), PREFIX[i])) + + +def parse_iso_date(inp): + return datetime.fromisoformat(inp) + + +def add_view_funcs(app): + app.jinja_env.filters["byte_size2"] = print_sizes2 + app.jinja_env.filters["byte_size10"] = print_sizes10 + app.context_processor(lambda: dict(parse_iso_date=parse_iso_date)) diff --git a/src/jwebmail/webmail.py b/src/jwebmail/webmail.py new file mode 100644 index 0000000..4e47dbd --- /dev/null +++ b/src/jwebmail/webmail.py @@ -0,0 +1,321 @@ +from urllib.parse import urlparse + +from flask import abort, current_app, flash, redirect, render_template, request, url_for +from flask_babel import gettext, lazy_gettext +from flask_login import UserMixin, current_user, login_user, logout_user +from flask_paginate import Pagination, get_page_parameter, get_per_page_parameter +from flask_wtf import FlaskForm +from wtforms import ( + EmailField, + MultipleFileField, + PasswordField, + StringField, + SubmitField, + TextAreaField, + validators, +) + +from .model.read_mails import QMAuthError +from .read_mails import add_user, get_read_mails_logged_in +from .read_mails import login as rm_login +from .render_mail import to_mime_type + + +class JWebmailUser(UserMixin): + def __init__(self, mail_addr, password): + self.id = mail_addr + self.password = password + + +class LoginForm(FlaskForm): + username = StringField(lazy_gettext("Username"), [validators.Email()]) + password = PasswordField( + lazy_gettext("Password"), [validators.Length(min=5, max=35)] + ) + + +class WriteForm(FlaskForm): + send_to = EmailField("Send to", [validators.InputRequired()]) + subject = StringField("Subject", [validators.InputRequired()]) + cc = StringField("CC") + bcc = StringField("BCC") + answer_to = EmailField("Answer to") + content = TextAreaField("Content") + attachments = MultipleFileField("Attachments") + submit = SubmitField("Send") + + +def login(): + if current_user.is_authenticated: + return redirect(url_for("displayheaders"), 307) + + form = LoginForm() + warn = "" + + if form.validate_on_submit(): + if rm_login(form.username.data, form.password.data): + user = JWebmailUser(form.username.data, form.password.data) + add_user(user) + login_user(user) + + next = request.args.get("next") + + if urlparse(next).netloc: + abort(401) + return redirect(next or url_for("displayheaders"), 303) + else: + warn = gettext("login failed") + + return render_template("login.html", login_form=form, warn=warn), 401 + + +def logout(): + logout_user() + return redirect(url_for("login"), 303) + + +def about(): + view_model = { + "scriptadmin": current_app.config["JWEBMAIL"]["ADMIN_MAIL"], + "http_host": request.host, + "request_uri": request.full_path, + "remote_addr": request.remote_addr, + "languages": current_app.extensions["babel"].instance.list_translations(), + } + return render_template("about.html", **view_model) + + +def displayheaders(folder=""): + folders = get_read_mails_logged_in().folders() + + if folder and folder not in folders: + return render_template("error", error="no_folder", links=folders), 404 + + sort = request.args.get("sort", "!date") + search = request.args.get("search") + + s = sort[1:] if sort[0] == "!" else sort + if s not in ["date", "size", "sender"]: + abort(400) + + count = get_read_mails_logged_in().count(folder) + + page = request.args.get(get_page_parameter(), type=int, default=1) + per_page = request.args.get(get_per_page_parameter(), type=int, default=10) + + pgn = Pagination( + page=page, + per_page=per_page, + total=count["total_mails"], + record_name="mails", + css_framework="bulma", + display_msg=gettext( + "displaying {start} - {end} of {total} {record_name}" + ), + inner_window=1, + outer_window=0, + ) + + if search: + headers = get_read_mails_logged_in().search(search, folder) + else: + headers = get_read_mails_logged_in().read_headers_for( + folder=folder, + start=(pgn.page - 1) * pgn.per_page, + end=(pgn.page - 1) * pgn.per_page + pgn.per_page, + sort=sort, + ) + + vals = { + "folder": folder, + "pgn": pgn, + "msgs": headers, + "mail_folders": folders, + "total_size": count["byte_size"], + "total_new_mails": count["unread_mails"], + } + return render_template("displayheaders.html", **vals) + + +def readmail(msgid): + try: + mail = get_read_mails_logged_in().show("", msgid) + except QMAuthError: + return render_template("not_found.html"), 404 + + return render_template("readmail.html", msg=mail) + + +def writemail(): + return render_template("writemail.html", form=WriteForm()) + + +def move(folder): + folders = get_read_mails_logged_in().folders() + + mm = request.args.getlist("mail") + to_folder = request.args["folder"] + + if folder not in folders or to_folder not in folders: + raise ValueError("folder not valid") + + for m in mm: + get_read_mails_logged_in().move(m, folder, to_folder) + + flash(gettext("succ_move")) + return redirect(url_for("displayheaders"), 303) + + +def rawmail(msgid): + path = request.args.get("path", "") + + content = get_read_mails_logged_in().raw("", msgid, path) + + headers = [] + + cd = content["head"].get("content_disposition") + if cd and cd.lower() == "attachment": + headers.append( + ( + "Content-Disposition", + f"attachment; filename={content['head']['filename']}", + ) + ) + ct = to_mime_type(content["head"]) + if ct.startswith("text/"): + ct += "; charset=UTF-8" + headers.append(("Content-Type", ct)) + + return content["body"], headers + + +def sendmail(): + form = WriteForm() + + if not form.validate(): + abort(400) + + mail = { + "to": form.to.data, + "message": form.content.data, + "subject": form.subject.data, + "cc": form.cc.data, + "bcc": form.bcc.data, + "reply": form.answer_to.data, + "attach": form.attachments.data, + "from": "", + } + + error = send_mail(mail) + + if error: + return render_template("writemail.html", warning=gettext("error_send")), 400 + + flash(gettext("succ_send")) + return redirect(url_for("displayheaders"), 303) + + +""" +sub remove { + my $self = shift; + + my $v = $self->validation; + $v->csrf_protect; + $v->required('mail'); + + if ($v->has_error) { + $self->reply->exception('errors in ' . join('', $v->failed->@*)); + return; + } + + my $auth = $self->stash(STS_AUTH); + + my $mm = $self->every_param('mail'); + my $folder = $self->stash('folder'); + + $self->users->remove($auth, $folder, $_) for @$mm; + + $self->res->code(303); + $self->redirect_to('displayheaders'); +} + + +### session password handling + +use constant { S_PASSWD => 'pw', S_OTP_S3D_PW => 'otp_s3d_pw' }; + +sub _rand_data { + my $len = shift; + + if (TRUE_RANDOM) { + #return makerandom_octet(Length => $len, Strength => 0); # was used for Crypt::Random + return urandom($len); + } + else { + my $res = ''; + for (0..$len-1) { + vec($res, $_, 8) = int rand 256; + } + + return $res; + } +} + +sub _session_passwd { + my ($self, $passwd, $challenge) = @_; + my $secAlg = LOGIN_SCHEME; + + $self->_warn_crypt; + + if (defined $passwd) { # set + if ($secAlg eq fc 'cram_md5') { + $self->session(S_PASSWD() => $passwd, challenge => $challenge); + } + elsif ($secAlg eq fc 'plain') { + unless ($passwd) { + $self->s3d(S_PASSWD, ''); + delete $self->session->{S_OTP_S3D_PW()}; + return; + } + die "'$passwd' contains invalid character \\n" if $passwd =~ /\n/; + if (length $passwd < 20) { + $passwd .= "\n" . ' ' x (20 - length($passwd) - 1); + } + my $passwd_utf8 = encode('UTF-8', $passwd); + my $rand_bytes = _rand_data(length $passwd_utf8); + $self->s3d(S_PASSWD, b64_encode($passwd_utf8 ^ $rand_bytes, '')); + $self->session(S_OTP_S3D_PW, b64_encode($rand_bytes, '')); + } + else { + die + } + } + else { # get + if ($secAlg eq fc 'cram_md5') { + wantarray or carp "you forgot the challenge"; + return ($self->session(S_PASSWD), $self->session('challenge')); + } + elsif ($secAlg eq fc 'plain') { + my $pw = b64_decode($self->s3d(S_PASSWD) || ''); + my $otp = b64_decode($self->session(S_OTP_S3D_PW) || ''); + my ($res) = split "\n", decode('UTF-8', $pw ^ $otp), 2; + return $res; + } + else { + die + } + } +} + +sub _warn_crypt { + my $self = shift; + + state $once = 0; + + if ( !TRUE_RANDOM && !$once && LOGIN_SCHEME eq fc 'plain' ) { + $self->log->warn("Falling back to pseudo random generation. Please install Crypt::URandom"); + $once = 1; + } +} + +""" -- cgit v1.2.3