diff options
author | Jannis M. Hoffmann <jannis@fehcom.de> | 2023-12-03 19:22:12 +0100 |
---|---|---|
committer | Jannis M. Hoffmann <jannis@fehcom.de> | 2023-12-03 19:22:12 +0100 |
commit | 2cf2a68bd1c25d8fe4f3126f40bd57982cc6b2a4 (patch) | |
tree | b5c8ed0e1cfe8eac311829296a9aca062bb1abc1 |
initial commit
30 files changed, 2234 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..66610ba --- /dev/null +++ b/.editorconfig @@ -0,0 +1,31 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[*.{js,py,sh,pl,pm,t}] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{css,scss}] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*{.html}] +indent_style = space +indent_size = 2 + +[*.md] +indent_style = space +indent_size = 2 + +[package.json] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad8d8bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.venv/ +tests/testdata/ +__pycache__/ diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..759e805 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,2 @@ +[python: **.py] +[jinja2: **/templates/**.html] diff --git a/package.json b/package.json new file mode 100644 index 0000000..cebd73b --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "jwebmail", + "version": "2.0.0", + "description": "", + "main": "index.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Jannis M. Hoffmann <jannis@fehcom.de>", + "license": "UNLICENSED", + "dependencies": { + "bulma": "^0.9.4", + "sass": "^1.69.5" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7761898 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "jwebmail" +requires-python = "~= 3.9" +authors = [ {name = "Jannis M. Hoffmann", email = "jannis@fehcom.de"} ] +description = "" +dynamic = ["version"] +dependencies = [ + "Flask", + "Flask-Babel", + "Flask-Login", + "Flask-WTF", + "flask-paginate", + "email-validator", +] + +[project.optional-dependencies] +dev = [ + "isort", + "black", + "pip-tools", + "click", +] +test = [ + "pytest", +] + +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[tool.isort] +profile = 'black' + +[tool.pip-tools] +generate-hashes = true +strip-extras = true + +[tool.flit.sdist] +include = [ + "script/", + "scss/", +] diff --git a/script/extract.py b/script/extract.py new file mode 100755 index 0000000..e771110 --- /dev/null +++ b/script/extract.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 + +"""qmauth.py + +Extract delivers information about emails from a maildir. +Runs with elevated privileges. + +This program is started by qmail-authuser with elevated privileges after +a successful login. +Input directives are provided as command line arguments. +Output is delivered via STDOUT as json and log information via STDERR. + +Exit codes:: + + 1 reserved + 2 reserved + 3 operational error (error message in output) + 4 user error (no output) + 5 issue switching to user (no output) + 110 reserved + 111 reserved +""" + +import email.parser +import email.policy +import json +import logging +import re +from argparse import ArgumentParser +from base64 import b64encode +from datetime import datetime +from glob import glob +from itertools import islice +from mailbox import Maildir, MaildirMessage +from os import environ, getpid, path, setuid +from pathlib import Path +from pwd import getpwnam +from sys import exit as sysexit +from sys import stdout + + +class MyMaildir(Maildir): + def __init__(self, dirname, *args, **kwargs): + self.__path = dirname + super().__init__(dirname, *args, **kwargs) + + def get_filename(self, mid): + p_cur = glob(path.join(self.__path, "cur", mid + "*")) + p_new = glob(path.join(self.__path, "new", mid + "*")) + res = p_cur + p_new + if len(res) != 1: + raise LookupError( + f"could not uniquely identify file for mail-id {mid!r}", mid + ) + return res[0] + + def get_folder(self, folder): + # copy from internal implementation + return MyMaildir( + path.join(self._path, "." + folder), + factory=self._factory, + create=False, + ) + + def _refresh(self): + super()._refresh() + for r in list(k for k in self._toc if k.startswith(".")): + del self._toc[r] + + +class QMAuthError(Exception): + def __init__(self, msg, **args): + self.msg = msg + self.info = args + + +def _adr(addrs): + if addrs is None: + return None + return [ + {"address": addr.addr_spec, "display_name": addr.display_name} + for addr in addrs.addresses + ] + + +def _get_rcv_time(mid): + idx = mid.find(".") + assert idx >= 0 + return float(mid[:idx]) + + +def startup(maildir, su, user, mode): + del environ["PATH"] + + netfehcom_uid = getpwnam(su).pw_uid + if not netfehcom_uid: + logging.error("user must not be root") + sysexit(5) + try: + setuid(netfehcom_uid) + except OSError: + logging.exception("error setting uid") + sysexit(5) + + def create_messages(mail_file): + if mode == count_mails: + msg = MaildirMessage(None) + elif mode == list_mails: + msg = MaildirMessage( + email.parser.BytesHeaderParser(policy=email.policy.default).parse( + mail_file + ) + ) + else: + msg = email.parser.BytesParser(policy=email.policy.default).parse(mail_file) + + return msg + + return MyMaildir( + maildir / user, + create=False, + factory=create_messages, + ) + + +def _sort_by_sender(midmsg): + _, msg = midmsg + + if len(addrs := msg["from"].addresses) == 1: + return addrs[0].addr_spec + else: + return msg["sender"].address.addr_spec + + +def _sort_mails(f, sort): + reverse = False + if sort.startswith("!"): + reverse = True + sort = sort[1:] + + def by_rec_date(midmsg): + return float(re.match(r"\d+\.\d+", midmsg[0], re.ASCII)[0]) + + if sort == "date": + keyfn = by_rec_date + elif sort == "sender": + keyfn = _sort_by_sender + elif sort == "subject": + keyfn = lambda midmsg: midmsg[1]["subject"] + elif sort == "size": + keyfn = lambda midmsg: path.getsize(f.get_filename(midmsg[0])) + elif sort == "": + keyfn = by_rec_date + else: + logging.warning("unknown sort-verb %r", sort) + reverse = False + keyfn = by_rec_date + + return keyfn, reverse + + +def _get_mime_head_info(msg): + return { + "content_maintype": msg.get_content_maintype(), + "content_subtype": msg.get_content_subtype(), + "content_disposition": msg.get_content_disposition(), + "filename": msg.get_filename(), + } + + +def _get_head_info(msg): + return { + "date": msg["date"].datetime.isoformat(), + "from": _adr(msg["from"]), + "sender": _adr(msg["sender"]), + "reply_to": _adr(msg["reply-to"]), + "to": _adr(msg["to"]), + "cc": _adr(msg["cc"]), + "bcc": _adr(msg["bcc"]), + "subject": msg["subject"], + "comments": msg["comments"], + "keywords": msg["keywords"], + "mime": _get_mime_head_info(msg), + } + + +def list_mails(f, start, end, sortby, folder): + assert 0 <= start <= end + + if folder: + f = f.get_folder(folder) + + if start == end: + return [] + + kfn, reverse = _sort_mails(f, sortby) + msgs = list(f.items()) + msgs.sort(key=kfn, reverse=reverse) + msgs = msgs[start : min(len(msgs), end)] + + return [ + { + "message_handle": mid, + "byte_size": path.getsize(f.get_filename(mid)), + "unread": "S" in msg.get_flags(), + "date_received": datetime.fromtimestamp(_get_rcv_time(mid)).isoformat(), + "head": _get_head_info(msg), + } + for mid, msg in msgs + ] + + +def count_mails(f, subfolder): + if subfolder: + f = f.get_folder(subfolder) + + return { + "total_mails": len(f), + "byte_size": sum(path.getsize(f.get_filename(mid)) for mid in f.keys()), + "unread_mails": len([1 for m in f if "S" in m.get_flags()]), + } + + +def _get_body(mail): + if not mail.is_multipart(): + if mail.get_content_maintype() == "text": + return mail.get_content() + else: + ret = mail.get_content() + if ret.isascii(): + return ret.decode(encoding="ascii") + elif len(ret) <= 128 * 1024: + return b64encode(ret).decode(encoding="ascii") + else: + raise QMAuthError( + "non attachment part too large (>512kB)", size=len(ret) + ) + + if (mctype := mail.get_content_maintype()) == "message": + msg = mail.get_content() + return { + "head": _get_head_info(msg), + "body": _get_body(msg), + } + elif mctype == "multipart": + ret = { + "preamble": mail.preamble, + "parts": [], + "epilogue": mail.epilogue, + } + for part in mail.iter_parts(): + head = _get_mime_head_info(part) + if head["content_disposition"] != "attachment": + body = _get_body(part) + else: + body = None + ret["parts"].append( + { + "head": head, + "body": body, + } + ) + return ret + else: + raise ValueError(f"unknown major content-type {mctype!r}") + + +def read_mail(f, subfolder, mid): + if subfolder: + f = f.get_folder(subfolder) + + msg = f.get(mid, None) + if not msg: + raise QMAuthError("no such message", mid=mid) + + return { + "head": _get_head_info(msg), + "body": _get_body(msg), + } + + +def _descent(xx): + head = _get_mime_head_info(xx) + if (mctype := head["content_maintype"]) == "message": + body = xx.get_content() + elif mctype == "multipart": + body = xx.iter_parts() + else: + body = xx.get_content() + return { + "head": head, + "body": body, + } + + +def raw_mail(f, subfolder, mid, path): + if subfolder: + f = f.get_folder(subfolder) + + msg = f.get(mid, None) + if not msg: + raise QMAuthError("no such message", mid=mid) + + pth = [int(seg) for seg in path.split(".")] if path else [] + mail = { + "head": {"content_maintype": "message", "content_subtype": "rfc822"}, + "body": msg, + } + + for n in pth: + mctype = mail["head"]["content_maintype"] + + if mctype == "multipart": + try: + res = next(islice(mail["body"], n, None)) + except StopIteration: + raise QMAuthError("out of bounds path for mail", path=pth) + mail = _descent(res) + elif mctype == "message": + assert n == 0 + mail = _descent(mail["body"]) + else: + raise QMAuthError( + f"can not descent into non multipart content type {mctype}" + ) + + if hasattr(mail["body"], "__next__"): + raise QMAuthError("can not stop at multipart section", path=pth) + + json.dump(mail["head"], stdout) + stdout.write("\n") + if isinstance(mail["body"], str): + stdout.write(mail["body"]) + elif isinstance(mail["body"], bytes): + stdout.flush() + stdout.buffer.write(mail["body"]) + else: + stdout.write(str(mail["body"])) + sysexit(0) + + +def _matches(m, pattern): + if m.is_multipart(): + return any( + 1 + for part in m.body.parts + if re.search(pattern, part.decoded()) or re.search(pattern, part.subject) + ) + return re.search(pattern, m.body.decoded()) or re.search(pattern, m.subject) + + +def search_mails(f, pattern: str, subfolder: str): + if subfolder: + f = f.get_folder(subfolder) + + return [ + { + "head": _get_head_info(msg), + "body": _get_body(msg), + } + for msg in f.values() + if _matches(msg, pattern) + ] + + +def folders(f): + return f.list_folders() + + +def move_mail(f, mid, from_, to): + if from_: + f = f.get_folder(from_) + + fname = Path(f.get_filename(mid)) + + assert to in f.list_folders() + + sep = -2 if not from_ else -3 + + if to: + res = fname.parts[:sep] + ("." + to,) + fname.parts[-2:] + else: + res = fname.parts[:sep] + fname.parts[-2:] + + fname.rename(Path(*res)) + + return 1 + + +def remove_mail(f, subdir, mid): + if subdir: + f = f.get_folder(subdir) + + f[mid].add_flag("T") + + return 1 + + +def parse_arguments(): + ap = ArgumentParser(allow_abbrev=False) + ap.add_argument("maildir_path", type=Path) + ap.add_argument("os_user") + ap.add_argument("mail_user") + + sp = ap.add_subparsers(title="methods", required=True) + + sp_list = sp.add_parser("list") + sp_list.add_argument("folder", metavar="subfolder") + sp_list.add_argument("start", type=int) + sp_list.add_argument("end", type=int) + sp_list.add_argument("sortby", metavar="sort_by") + sp_list.set_defaults(run=list_mails) + + sp_count = sp.add_parser("count") + sp_count.add_argument("subfolder") + sp_count.set_defaults(run=count_mails) + + sp_read = sp.add_parser("read") + sp_read.add_argument("subfolder") + sp_read.add_argument("mid", metavar="message") + sp_read.set_defaults(run=read_mail) + + sp_raw = sp.add_parser("raw") + sp_raw.add_argument("subfolder") + sp_raw.add_argument("mid", metavar="message") + sp_raw.add_argument("path", default="") + sp_raw.set_defaults(run=raw_mail) + + sp_folders = sp.add_parser("folders") + sp_folders.set_defaults(run=folders) + + sp_move = sp.add_parser("move") + sp_move.add_argument("mid", metavar="message") + sp_move.add_argument("from_", metavar="from") + sp_move.add_argument("to") + sp_move.set_defaults(run=move_mail) + + sp_remove = sp.add_parser("remove") + sp_remove.add_argument("subdir") + sp_remove.add_argument("mid", metavar="message") + sp_remove.set_defaults(run=remove_mail) + + sp_search = sp.add_parser("search") + sp_search.add_argument("pattern") + sp_search.add_argument("subfolder") + sp_search.set_defaults(run=search_mails) + + return vars(ap.parse_args()) + + +def main(): + try: + logging.basicConfig( + level="INFO", + format="%(levelname)s:" + str(getpid()) + ":%(message)s", + ) + args = parse_arguments() + logging.debug("started with %s", args) + s = startup( + args.pop("maildir_path"), + args.pop("os_user"), + args.pop("mail_user"), + args["run"], + ) + logging.debug("setuid successful") + run = args.pop("run") + reply = run(s, **args) + json.dump(reply, stdout) + except QMAuthError as qerr: + errmsg = dict(error=qerr.msg, **qerr.info) + json.dump(errmsg, stdout) + sysexit(3) + except Exception: + logging.exception("qmauth.py error") + sysexit(4) + + +if __name__ == "__main__": + main() diff --git a/script/testauthenticator.py b/script/testauthenticator.py new file mode 100755 index 0000000..91767ad --- /dev/null +++ b/script/testauthenticator.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +import logging +import os +import sys + +VALID_USER = b"mockmaildir@example.org" +VALID_PW = b"12345" + + +def main(): + with os.fdopen(3, "rb") as authfd: + inp = authfd.read() + + u, p, *r = inp.split(b"\0") + if len(r) > 2: + logging.warning("too many fields!") + sys.exit(2) + + if r and r[0]: + raise ValueError("cram currently not supported") + else: + if u == VALID_USER and p == VALID_PW: + os.execvp(sys.argv[1], sys.argv[1:]) + + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scss/my_bulma.scss b/scss/my_bulma.scss new file mode 100644 index 0000000..01cdc4b --- /dev/null +++ b/scss/my_bulma.scss @@ -0,0 +1,46 @@ +@use "bulma/bulma"; + +.jwm-new-mail > .media-content { + @extend .has-text-weight-semibold; +} + +dl.jwm-mail-header { + $left-xx: 130px; + + & dt { + font-weight: bold; + @media screen and (min-width: bulma.$desktop) { + float: left; + clear: left; + text-align: right; + width: $left-xx; + } + } + & dd { + @media screen and (min-width: bulma.$desktop) { + margin-left: #{$left-xx + 10px}; + } + margin-left: bulma.$size-7; + } +} + +.jwm-mail { + @extend .box; +} + +.jwm-mail-header { + @extend .block; +} + +.jwm-mail-body { + @extend .block; +} + +iframe.jwm-mail-body-text-html { + width: 100%; + height: 400px; +} + +.jwm-mail-body-text-plain { + @extend .block; +} 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; + } +} + +""" |