diff options
Diffstat (limited to 'src')
22 files changed, 1582 insertions, 0 deletions
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/<folder>", view_func=dh) + + app.add_url_rule( + "/read/<msgid>", endpoint="read", view_func=login_required(readmail) + ) + app.add_url_rule("/raw/<msgid>", 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/<folder>", 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'<div class="jwm-mail-body-text-plain"><pre>{escape(content)}</pre></div>\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'<iframe src="{url}" class="jwm-mail-body-text-html" ></iframe>\n' + + +def render_image(subtype, content, _path): + return f'<img src="data:image/{subtype};base64,{escape(content)}" />\n' + + +def render_multipart_alternative(_subtype, content, path): + parts = content["parts"] + T = "<div class=tabs><ul>\n" + C = "<div class=jwm-mail-body-multipart-alternative-bodies>" + init, *rest = reversed(parts) + + T += f"<li class=is-active data=0><a>{to_mime_type(init['head'])}</a></li>" + + C += "<div class=jwm-mail-body-multipart-alternative-body>\n" + C += mime_render( + *to_mime_types(init["head"]), init["body"], path + (len(parts) - 1,) + ) + C += "</div>\n" + + for i, r in enumerate(rest, 1): + T += f"<li data={i}><a>{to_mime_type(r['head'])}</a></li>\n" + + C += '<div class="jwm-mail-body-multipart-alternative-body is-hidden">\n' + C += mime_render( + *to_mime_types(r["head"]), r["body"], path + (len(parts) - 1 - i,) + ) + C += "</div>\n" + + C += "</div>" + T += "</ul></div>" + script_url = url_for("static", filename="src/rendermail.js") + return f'<script src="{script_url}"></script><div class="jwm-mail-body jwm-mail-body-multipart-alternative">\n{T}\n{C}\n</div>\n' + + +def render_multipart(_subtype, content, path): + parts = content["parts"] + R = '<div class="jwm-mail-body jwm-mail-body-multipart">\n' + + for i, p in enumerate(parts): + R += "<div class=media><div class=media-content>\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 += "<p>" + R += f'<a href="{ref_url}" download="{escape(p["head"]["filename"])}">\n' + R += f"{escape(link_text)}</a>\n" + R += "</p>\n" + else: + current_app.log.warning( + "unknown Content-Disposition %s", p["head"]["content_disposition"] + ) + R += f"<p>unknown Content-Disposition {p['head']['content_disposition']}</p>\n" + + R += "</div></div>\n" + + return R + "</div>\n" + + +def _format_header(category, value): + R = "" + + if isinstance(value, list) and value: + R += f"<dt>{escape(category)}</dt>\n" + for v in value: + value = ( + f'"{v["display_name"]}" <{v["address"]}>' + if v["display_name"] + else v["address"] + ) + R += f"<dd>{escape(value)}<dd>\n" + + return R + + +def render_message(subtype, msg, path): + if subtype != "rfc822": + current_app.log.warning("unknown message mime-subtype %s", subtype) + + R = '<div class="jwm-mail">' + + R += '<dl class="jwm-mail-header">' + R += f"<dt>{escape(gettext('Subject'))}</dt>" + R += f"<dd>{escape(msg['head']['subject'])}</dd>\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"<dt>{escape(gettext('Date'))}</dt>" + R += f"<dd>{escape(msg['head']['date'])}</dd>\n" + R += f"<dt>{escape(gettext('Content-Type'))}</dt>" + R += f"<dd>{to_mime_type(msg['head']['mime'])}</dd>\n" + R += "</dl>\n" + + R += mime_render(*to_mime_types(msg["head"]["mime"]), msg["body"], path + (0,)) + + return R + "</div>\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'<p class="jwm-body-unsupported">Unsupported MIME type of <code>{maintype}/{subtype}</code>.</p>\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 @@ +<div class="columns"> + + <div class="column"> + {{ pgn.links }} + </div> + + <div class="column"> + <form href="{{ url_for('move', folder=folder) }}" id='move-mail'> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label for="select-folder" class="label">{% trans %}Move to{% endtrans %}</label> + </div> + <div class=field-body> + <div class="field is-grouped"> + <div class=control> + <div class=select> + <select name=select-folder> + {% for f in mail_folders if f is ne folder %} + <option type=select name="folder" value="{{ f }}">{{ f or gettext('Home') }}</option> + {% endfor %} + </select> + </div> + </div> + {# csrf_field #} + <div class=control> + <input type=submit class=button value="{{ gettext('Move') }}"> + </div> + </div> + </div> + </div> + </form> + </div> + + <div class=column> + <form href="{{ url_for('move', folder=folder) }}" id="remove-mail" method=POST> + {# csrf_field #} + <div class=control> + <input id=remove type=submit class=button value="{{ gettext('Remove') }}"> + </div> + </form> + </div> + + <div class="column has-text-right"> + <label for=allbox>{% trans %}check all{% endtrans %}</label> + <input name=allbox type=checkbox id=check-all> + </div> + +</div> 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 @@ +<div class="columns"> + + <div class="column"> + <nav class="navbar"> + + <div class="navbar-brand"> + <span class=navbar-item> + <b>{{ folder }}</b> + </span> + <a role="button" class="navbar-burger" data-target="navMenu" id=navbar-toggle> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> + + <div class="navbar-menu" id="navMenu"> + <div class=navbar-start> + {% for f in mail_folders if f is ne folder %} + <a href="{{ url_for('displayheaders', folder=f) }}" class="navbar-item"> + {{ f or gettext('Home') }} + </a> + {% endfor %} + </div> + </div> + </nav> + </div> + + <div class="column"> + <div class="columns is-multiline is-mobile"> + <span class="column is-half-mobile has-text-centered"> + {{ pgn.info }} + </span> + <span class="column is-half-mobile has-text-centered"> + {% if total_new_mails %} + {% trans %}{{ total_new_mails }} new{% endtrans %} + {% endif %} + </span> + <span class="column has-text-centered"> + {% if total_size %} + {% trans %}mailbox size: {% endtrans %} + {{ total_size|byte_size10 }} + {% endif %} + </span> + </div> + </div> + +</div> 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 @@ +<section class="box"> + + {% for msg in msgs %} + + <tag class="media {{ jwm-new-mail if msg.unread else '' }}" id="{{ msg.message_handle }}"> + <div class="media-left is-hidden-mobile"> + {{ loop.index + (pgn.page - 1) * pgn.per_page }} + </div> + + <div class="media-content"> + <div class="columns is-gapless is-multiline"> + <div class="column is-10"> + {{ 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 }} + </div> + + <div class="column is-2"> + {{ parse_iso_date(msg.head.date)|datetimeformat }} + </div> + + <div class="column is-10"> + <a href="{{ url_for('read', msgid=msg.message_handle) }}">{{ msg.head.subject or '_' }}</a> + </div> + + <div class="column is-2"> + {{ msg.byte_size|byte_size10 }} + </div> + </div> + </div> + + <div class=media-right> + <input type="checkbox" name="{{ msg.message_handle }}" form="move-mail remove-mail" class="jwm-mail-checkbox"> + </div> + </tag> + + {% endfor %} + +</section> 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 @@ +<div class="columns"> + + <nav class="column"> + <a href="{{ url_for('logout') }}" class="button">{% trans %}Logout{% endtrans %}</a> + <a href="{{ url_for('write') }}" class="button">{% trans %}Write{% endtrans %}</a> + </nav> + + <form class=column> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label for=search class=label>{% trans %}Search{% endtrans %}</label> + </div> + <div class=field-body> + <div class=field> + <div class=control> + <input type=search id=search size=8 class=input /> + </div> + </div> + </div> + </div> + </form> + + <form class=column> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label for=sort class=label>{% trans %}Sort{% endtrans %}</label> + </div> + <div class=field-body> + <div class=field> + <div class="select" id=sort-select> + <select name=sort id=sort> + <option value="!date">{% trans %}Date{% endtrans %} - {% trans %}Descending{% endtrans %}</option> + <option value="date">{% trans %}Date{% endtrans %} - {% trans %}Ascending{% endtrans %}</option> + <option value="!size">{{ gettext('Size') }} - {{ gettext('Descending') }}</option> + <option value="!sender">{{ gettext('Sender') }} - {{ gettext('Descending') }}</option> + <option value="sender">{{ gettext('Sender') }} - {{ gettext('Ascending') }}</option> + </select> + </div> + </div> + </div> + </div> + </form> + + <div class=column> + {{ pgn.links }} + </div> + +</div> 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 %} +<div class="section container"> + + <article class=content> + + <h1>About JWebmail {{ version }}</h1> + + <p> + JWebmail {{ version }} is a Webmail solution meant to be used with + <a href="https://www.fehcom.de/sqmail/sqmail.html">s/qmail</a> + </p> + + <h3>Features</h3> + <ul> + <!-- + <li>qmail, vmailmgr and vpopmail authentication support (<em>not</em> sendmail)</li> + <li>multiple signatures und headers support</li> + <li>basic folders support (4 defined folders)</li> + <li>featured addressbook</li> + <li>100% Maildir based</li> + <li>reads the mail directely from the server disk, without need for POP3 or IMAP</li> + --> + <li>multiple language support</li> + <li>session management </li> + <li>search for mails</li> + <li>CGI support but also psgi/plack and fcgi</li> + </ul> + + <p> + This is a + <a href="http://www.gnu.org/copyleft/gpl.html" target="_new">GPL</a> + licensed project, created by <a href="mailto:">Oliver 'omnis' Müller</a> + and currently maintained by + <a href="mailto:jannis@fehcom.de">Jannis M. Hoffmann</a> + </p> + + <h3>Supported languages</h3> + <ul> +{% for lang in languages %} + <li> + {{ get_locale().languages[lang.language] }} + </li> +{% endfor %} + </ul> + + <p> + JWebmail is programmed in <a href="http://www.perl.org">Perl</a>, and is + a complete rewrite of oMail-webmail. + </p> + + </article> + + <nav class=navbar> + <div class=navbar-item> + <a href="{{ url_for('login') }}" class="button">{% trans %}Login{% endtrans %}</a> + </div> + </nav> + +</div> +{% 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 %} + <script src="{{ url_for('static', filename='src/displayheaders.js') }}" defer> + </script> +{% endblock %} + +{% block content %} + <section class="section container"> + + {% include '_folders.html' %} + + {% if loginmessage is defined %} + <p id=loginmessage> + {{ loginmessage }} + </p> + {% endif %} + + {% include '_top_nav.html' %} + + {% if msgs %} + {% include '_main_table.html' %} + {% else %} + <p class="section"> + {% trans %}This folder is empty!{% endtrans %} + </p> + {% endif %} + + {% include '_bot_nav.html' %} + + </section> +{% 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 @@ +<!doctype html> + +<html> + + <head> + <title>Error</title> + </head> + + <body> + <h1>Error</h1> + <p class=center> + {% if error is defined %} + {{ error }} + {% else %} + Uwu :( + {% endif %} + </p> + + {% if links is defined %} + See: + <nav> + {% for link in links %} + <a href="{{ link }}">{{ link }}</a> + <br> + {% endif %} + </nav> + {% endif %} + </body> + +</html> 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 %} +<section class=section> + <div class="container is-max-desktop box"> + + <h1 class=title> + {% trans %}Login{% endtrans %} + </h1> + <h2 class=subtitle> + JWebmail + </h2> + +{% if login_form.errors %} + <div class="message is-warning"> + <div class=message-header> + {{ login_form.errors }} + </div> + </div> +{% endif %} +{% if warn %} + <div class="message is-warning"> + <div class=message-header> + {{ warn }} + </div> + </div> +{% endif %} + + <form name="login1" method="POST" class="pure-form pure-form-aligned jwm-round"> + + {{ login_form.csrf_token }} + + <div class="field is-horizontal"> + <div class="field-label is-normal"> + {{ login_form.username.label(class='label') }} + </div> + <div class=field-body> + <div class=field> + <div class=control> + {{ login_form.username(class='input') }} + </div> + </div> + </div> + </div> + + <div class="field is-horizontal"> + <div class="field-label is-normal"> + {{ login_form.password.label(class='label') }} + </div> + <div class=field-body> + <div class=field> + <div class=control> + {{ login_form.password(class='input') }} + </div> + </div> + </div> + </div> + <div class="field is-horizontal"> + <div class=field-label> + </div> + <div class=field-body> + <div class=field> + <div class=control> + <input type=submit class="button is-primary" name=submit_button value="{{ gettext('Login') }}"> + </div> + </div> + </div> + </div> + </form> + + </div> +</section> +{% endblock %} + +{% block scripts %} + <script type="text/javascript"> + if (!document.login1.userid.value) { + document.login1.userid.focus(); + } else { + document.login1.password.focus(); + } + </script> +{% 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 @@ +<!doctype html> + +<html lang="{{ get_locale().language }}"> + +<head> + <meta charset=utf-8> + <meta name="viewport" content="width=device-width, initial-scale=1"> + + <link rel=stylesheet href="{{ url_for('static', filename="css/my_bulma.css") }}" > + + <title> + {{ title or 'JWebmail' }} + </title> +</head> + +<body> + {% block content required %}{% endblock %} + + <footer class=footer> + <div class="content has-text-centered"> + <a href="{{ url_for('about') }}"> + {% trans %}About{% endtrans %} JWebmail + </a> + <br> + {% trans %}Version{% endtrans %} + {{ version }} + </div> + </footer> + + {% block scripts %}{% endblock %} + +</body> + +</html> 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 @@ +<html> + + <head> + <meta charset=utf-8> + <meta name="viewport" content="width=device-width, initial-scale=1"> + + <title>Not Found</title> + + <link type=stylesheet href="{{ url_for('static', filename='css/my_bulma.css') }}"> + </head> + + <body> + <section class=hero> + <div class=hero-body> + <h1 class=title> + Not the page you are looking for. + </h1> + <p> + Go back or go to the <a href="{{ url_for('login') }}">{% trans %}start page{% endtrans %}</a>. + </p> + </div> + </section> + </body> + +</html> 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 %} + <div class="section container"> + + <h1 class=title>Read Mail</h1> + + {{ format_mail(msg) }} + + <nav> + <a href="javascript:history.back()" class="button"> + {% trans %}back{% endtrans %} + </a> + </nav> + + </div> +{% 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 %} + <div class="section container"> + + <h1 class=title>Write Message</h1> + + {% if warning is defined %} + <p class=message> {{ warning }} </p> + {% endif %} + + <form method="post" enctype="multipart/form-data"> + + <div class=field> + {{ form.send_to.label(class='label') }} + <div class=control> + {{ form.send_to(class='input') }} + </div> + </div> + + <div class=field> + {{ form.subject.label(class='label') }} + <div class=control> + {{ form.subject(class='input') }} + </div> + </div> + + <div class=field> + {{ form.cc.label(class='label') }} + <div class=control> + {{ form.cc(class='input') }} + </div> + </div> + + <div class=field> + {{ form.bcc.label(class='label') }} + <div class=control> + {{ form.bcc(class='input') }} + </div> + </div> + + <div class=field> + {{ form.answer_to.label(class='label') }} + <div class=control> + {{ form.answer_to(class='input') }} + </div> + </div> + + <div class=field> + {{ form.content.label(class='label') }} + <div class=control> + {{ form.content(class='textarea', rows=10) }} + </div> + </div> + + <div class=field> + <div class=file> + <label class=file-label> + {{ form.attachments(class='file-input') }} + <span class="file-cta"> + <span class=file-label> + {% trans %}attach file{% endtrans %} + </span> + </span> + </label> + </div> + </div> + + <div class=field> + <div class=control> + <input type=submit class=button value="{{ gettext('Send') }}"> + </div> + </div> + + </form> + + <nav> + <a href="javascript:history.back()" class="button">{% trans %}back{% endtrans %}</a> + </nav> + + </div> +{% 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 <jannis@fehcom.de>, 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 <EMAIL@ADDRESS>\n" +"Language: de\n" +"Language-Team: de <LL@li.org>\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 <b>{start} - {end}</b> of <b>{total}</b> {record_name}" +msgstr "zeige <b>{start} - {end}</b> von <b>{total}</b> {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 <b>{start} - {end}</b> of <b>{total}</b> {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; + } +} + +""" |