summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJannis M. Hoffmann <jannis@fehcom.de>2023-12-03 19:22:12 +0100
committerJannis M. Hoffmann <jannis@fehcom.de>2023-12-03 19:22:12 +0100
commit2cf2a68bd1c25d8fe4f3126f40bd57982cc6b2a4 (patch)
treeb5c8ed0e1cfe8eac311829296a9aca062bb1abc1
initial commit
-rw-r--r--.editorconfig31
-rw-r--r--.gitignore4
-rw-r--r--babel.cfg2
-rw-r--r--package.json18
-rw-r--r--pyproject.toml42
-rwxr-xr-xscript/extract.py479
-rwxr-xr-xscript/testauthenticator.py30
-rw-r--r--scss/my_bulma.scss46
-rw-r--r--src/jwebmail/__init__.py91
-rw-r--r--src/jwebmail/css.py17
-rw-r--r--src/jwebmail/model/read_mails.py128
-rw-r--r--src/jwebmail/read_mails.py50
-rw-r--r--src/jwebmail/render_mail.py160
-rw-r--r--src/jwebmail/static/src/displayheaders.js35
-rw-r--r--src/jwebmail/static/src/rendermail.js21
-rw-r--r--src/jwebmail/templates/_bot_nav.html48
-rw-r--r--src/jwebmail/templates/_folders.html48
-rw-r--r--src/jwebmail/templates/_main_table.html37
-rw-r--r--src/jwebmail/templates/_top_nav.html48
-rw-r--r--src/jwebmail/templates/about.html62
-rw-r--r--src/jwebmail/templates/displayheaders.html32
-rw-r--r--src/jwebmail/templates/exception_.html30
-rw-r--r--src/jwebmail/templates/login.html83
-rw-r--r--src/jwebmail/templates/mainlayout.html34
-rw-r--r--src/jwebmail/templates/not_found.html25
-rw-r--r--src/jwebmail/templates/readmail.html17
-rw-r--r--src/jwebmail/templates/writemail.html82
-rw-r--r--src/jwebmail/translations/de/LC_MESSAGES/messages.po167
-rw-r--r--src/jwebmail/view.py46
-rw-r--r--src/jwebmail/webmail.py321
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("{}&nbsp;{}".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("{}&nbsp;{}".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;
+ }
+}
+
+"""