diff --git a/mailpile-multiplexer/daemon.py b/mailpile-multiplexer/daemon.py
new file mode 100755
index 00000000..b3a5d5cf
--- /dev/null
+++ b/mailpile-multiplexer/daemon.py
@@ -0,0 +1,272 @@
+#!/usr/bin/python3
+
+# A Mailpile multiplexer
+# ----------------------
+#
+# Mailpile has no built-in notion of user authentication. It's
+# a single-user thing. This file provides a proxy server around
+# Mailpile that interfaces with Mail-in-a-Box user authentication.
+# When a user logs in, a new Mailpile instance is forked and we
+# proxy to that Mailpile for that user's session.
+
+import sys, os, os.path, re, urllib.request, urllib.parse, urllib.error, time
+
+from flask import Flask, request, session, render_template, redirect, abort
+app = Flask(__name__)
+
+sys.path.insert(0, 'management')
+import utils
+env = utils.load_environment()
+
+running_mailpiles = { }
+
+@app.route('/')
+def index():
+ return render_template('frameset.html')
+
+@app.route('/status')
+def status():
+ # If the user is not logged in, show a blank page because we'll
+ # display the login form in the mailpile frame.
+ if "auth" not in session:
+ return "
"
+
+ # Show the user's current logged in status.
+ return render_template('login_status.html', auth=session.get("auth"))
+
+@app.route('/refresh-frameset')
+def refresh_frameset():
+ # Force a reload of the frameset from within a frame.
+ return """
+
+
+
+
+
+"""
+
+@app.route('/logout')
+def logout():
+ session.pop("auth", None)
+ return redirect('/refresh-frameset')
+
+@app.route('/mailpile', methods=['GET', 'POST'])
+def mailpile():
+ # If the user is not logged in, show a login form.
+ if "auth" not in session:
+ return login_form()
+ else:
+ return proxy_request_to_mailpile('/')
+
+@app.route('/mailpile/', methods=['GET', 'POST'])
+def mailpile2(path):
+ # On inside pages, if the user is not logged in, then we can't
+ # really safely redirect to a login page because we don't know
+ # what sort of resource is being requested.
+ if "auth" not in session:
+ abort(403)
+ else:
+ return proxy_request_to_mailpile('/' + path)
+
+def login_form():
+ if request.method == 'GET':
+ # Show the login form.
+ return render_template('login.html', hostname=env["PRIMARY_HOSTNAME"])
+ else:
+ # Process the login form.
+ if request.form.get('email', '').strip() == '' or request.form.get('password', '').strip() == '':
+ error = "Enter your email address and password."
+ else:
+ # Get form fields.
+ email = request.form['email'].strip()
+ pw = request.form['password'].strip()
+ remember = request.form.get('remember')
+
+ # See if credentials are good.
+ try:
+ # Use doveadm to check credentials.
+ utils.shell('check_call', [
+ "/usr/bin/doveadm",
+ "auth", "test",
+ email, pw
+ ])
+
+ # If no exception was thrown, credentials are good!
+ if remember: session.permanent = True
+ session['auth'] = {
+ "email": email,
+ }
+
+ # Use Javascript to reload the whole frameset so that we
+ # can trigger a reload of the top frame that shows login
+ # status.
+ return redirect('/refresh-frameset')
+
+ except:
+ # Login failed.
+ error = "Email address & password did not match."
+
+ return render_template('login.html',
+ hostname=env["PRIMARY_HOSTNAME"],
+ error=error,
+ email=request.form.get("email"), password=request.form.get("password"), remember=request.form.get("remember"))
+
+def proxy_request_to_mailpile(path):
+ # Proxy the request.
+ port = get_mailpile_port(session['auth']['email'])
+
+ # Munge the headers. (http://www.snip2code.com/Snippet/45977/Simple-Flask-Proxy/)
+ headers = dict([(key.upper(), value) for key, value in request.headers.items() if key.upper() != "HOST"])
+
+ # Is this a POST? Re-create the request body.
+ data = None
+ if request.method == "POST":
+ data = urllib.parse.urlencode(list(request.form.items(multi=True)), encoding='utf8').encode('utf8')
+ headers['CONTENT-LENGTH'] = str(len(data))
+
+ # Configure request.
+ req = urllib.request.Request(
+ "http://localhost:%d%s" % (port, path),
+ data,
+ headers=headers,
+ )
+
+ # Execute request.
+ try:
+ response = urllib.request.urlopen(req)
+ body = response.read()
+ headers = dict(response.getheaders())
+ content_type = response.getheader("content-type", default="")
+ response_status = response.status
+ except urllib.error.HTTPError as e:
+ # Exceptions (400s, 500s, etc) are response objects too?
+ body = e.read()
+ headers = dict(e.headers)
+ content_type = "?/?" # don't do any munging
+ response_status = e.code
+
+ # Munge the response.
+
+ def rewrite_url(href):
+ # Make the URL absolute.
+ import urllib.parse
+ href2 = urllib.parse.urljoin(path, href.decode("utf8"))
+ if urllib.parse.urlparse(href2).scheme == "":
+ # This was a relative URL that we are proxying.
+ return b'/mailpile' + href2.encode("utf8")
+ return href
+
+ if content_type.startswith("text/html"):
+ # Rewrite URLs in HTML responses.
+ body = re.sub(rb" (href|src|HREF|SRC)=('[^']*'|\"[^\"]*\")",
+ lambda m : b' ' + m.group(1) + b'=' + m.group(2)[0:1] + rewrite_url(m.group(2)[1:-1]) + m.group(2)[0:1],
+ body)
+
+ if content_type.startswith("text/css"):
+ # Rewrite URLs in CSS responses.
+ body = re.sub(rb"url\('([^)]*)'\)", lambda m : b"url('" + rewrite_url(m.group(1)) + b"')", body)
+
+ if content_type.startswith("text/javascript"):
+ # Rewrite URLs in Javascript responses.
+ body = re.sub(rb"/(?:api|async)[/\w]*", lambda m : rewrite_url(m.group(0)), body)
+ body = re.sub(rb"((?:message_draft|message_sent|tags)\s+:\s+\")([^\"]+)", lambda m : m.group(1) + rewrite_url(m.group(2)), body)
+
+ #if content_type == "application/json":
+ # body = json.loads(body.decode('utf8'))
+ # def procobj(obj, is_urls=False):
+ # if not isinstance(obj, dict): return
+ # for k, v in obj.items():
+ # if is_urls: obj[k] = rewrite_url(v.encode('utf8')).decode('utf8')
+ # procobj(v, is_urls=(k == "urls"))
+ # procobj(body)
+ # body = json.dumps(body).encode('utf8')
+
+ # Pass back response to the client.
+ return (body, response_status, headers)
+
+def get_mailpile_port(emailaddr):
+ if emailaddr not in running_mailpiles:
+ running_mailpiles[emailaddr] = spawn_mailpile(emailaddr)
+ return running_mailpiles[emailaddr]
+
+def spawn_mailpile(emailaddr):
+ # Spawn a new instance of Mailpile that will automatically die
+ # when this process exits (because then we've lost track of the
+ # Mailpile instances we started).
+ #
+ # To do that, use an inspired idea from http://stackoverflow.com/questions/284325/how-to-make-child-process-die-after-parent-exits
+ # which uses an intermediate process that catches a SIGPIPE from the parent.
+ # We don't need an intermediate process because Mailpile is waiting
+ # for commands on STDIN. By giving it a STDIN that is a file descriptor
+ # that we never write to but keep open, the process should die as soon
+ # as this process exits due to a SIGPIPE.
+
+ # Prepare mailpile.
+
+ user, domain = emailaddr.split("@")
+ mp_home = os.path.join(env['STORAGE_ROOT'], 'mail/mailpile', utils.safe_domain_name(domain), utils.safe_domain_name(user))
+ maildir = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes', utils.safe_domain_name(domain), utils.safe_domain_name(user))
+ port = 10300 + len(running_mailpiles)
+
+ def mp(*args):
+ cmd = [os.path.join(os.path.dirname(__file__), '../externals/Mailpile/mp')] + list(args)
+ utils.shell("check_call", cmd, env={ "MAILPILE_HOME": mp_home })
+
+ os.makedirs(mp_home, exist_ok=True)
+ mp("--setup")
+ mp("--add", maildir, "--rescan", "all")
+
+ # Create OS file descriptors for two ends of a pipe.
+ # The pipe's write end remains open until the process dies, which is right.
+ # But we close the read end immediately.
+
+ pipe_r, pipe_w = os.pipe()
+ os.close(pipe_r)
+
+ # Span mailpile in a way that lets us control its stdin.
+ mailpile_proc = \
+ utils.shell("Popen",
+ [
+ os.path.join(os.path.dirname(__file__), '../externals/Mailpile/mp'),
+ "--www",
+ "--set", "sys.http_port=%d" % port,
+ "--set", "profiles.0.email=%s" % emailaddr,
+ "--set", "profiles.0.name=%s" % emailaddr,
+ ],
+ stdin=pipe_w,
+ env={ "MAILPILE_HOME": mp_home }
+ )
+
+ # Give mailpile time to start before trying to send over a request.
+ time.sleep(3)
+
+ return port
+
+# APP
+
+if __name__ == '__main__':
+ # Debugging, logging.
+
+ if "DEBUG" in os.environ: app.debug = True
+
+ if not app.debug:
+ app.logger.addHandler(utils.create_syslog_handler())
+
+ # Secret key.
+ key_file = "/tmp/mailpile-multiplexer-key"
+ try:
+ import base64
+ with open(key_file, "rb") as f:
+ app.secret_key = base64.b64decode(f.read().strip())
+ except IOError:
+ # Generate a fresh secret key, invalidating any sessions.
+ app.secret_key = os.urandom(24)
+ with utils.create_file_with_mode(key_file, 0o640) as f:
+ f.write(base64.b64encode(app.secret_key).decode('ascii') + "\n")
+
+ # Start
+
+ app.run(port=10223)
+
diff --git a/mailpile-multiplexer/templates/frameset.html b/mailpile-multiplexer/templates/frameset.html
new file mode 100644
index 00000000..c2dcc279
--- /dev/null
+++ b/mailpile-multiplexer/templates/frameset.html
@@ -0,0 +1,9 @@
+
+
+ Mailpile
+
+
+
diff --git a/mailpile-multiplexer/templates/login.html b/mailpile-multiplexer/templates/login.html
new file mode 100644
index 00000000..283b2ccd
--- /dev/null
+++ b/mailpile-multiplexer/templates/login.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+ {{hostname}}
+
+
+
+
+
+
+
+
{{hostname}}
+
Log in here to your Mailpile webmail.
+ {% if error %}
+
{{error}}
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mailpile-multiplexer/templates/login_status.html b/mailpile-multiplexer/templates/login_status.html
new file mode 100644
index 00000000..a9b21c7e
--- /dev/null
+++ b/mailpile-multiplexer/templates/login_status.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ You are logged in as {{auth.email}}.
+ Log out?
+
+
\ No newline at end of file
diff --git a/management/auth.py b/management/auth.py
index dee8c7e5..e1dc4ffb 100644
--- a/management/auth.py
+++ b/management/auth.py
@@ -8,6 +8,8 @@ from mailconfig import get_mail_user_privileges
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
+from utils import create_file_with_mode
+
class KeyAuthService:
"""Generate an API key for authenticating clients
@@ -27,13 +29,6 @@ class KeyAuthService:
authorized to access the API by granting group/ACL read permissions on
the key file.
"""
- def create_file_with_mode(path, mode):
- # Based on answer by A-B-B: http://stackoverflow.com/a/15015748
- old_umask = os.umask(0)
- try:
- return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w')
- finally:
- os.umask(old_umask)
os.makedirs(os.path.dirname(self.key_path), exist_ok=True)
diff --git a/management/utils.py b/management/utils.py
index ffe49734..fa33011e 100644
--- a/management/utils.py
+++ b/management/utils.py
@@ -1,4 +1,4 @@
-import os.path
+import os, os.path, subprocess
CONF_DIR = os.path.join(os.path.dirname(__file__), "../conf")
@@ -136,11 +136,12 @@ def is_pid_valid(pid):
else:
return True
-def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False, input=None):
- # A safe way to execute processes.
- # Some processes like apt-get require being given a sane PATH.
- import subprocess
+def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, trap=False,
+ input=None, stdin=subprocess.DEVNULL):
+ # A safe way to execute processes.
+
+ # Some processes like apt-get require being given a sane PATH.
env.update({ "PATH": "/sbin:/bin:/usr/sbin:/usr/bin" })
kwargs = {
'env': env,
@@ -148,6 +149,10 @@ def shell(method, cmd_args, env={}, capture_stderr=False, return_bytes=False, tr
}
if method == "check_output" and input is not None:
kwargs['input'] = input
+ else:
+ kwargs['stdin'] = stdin
+
+ #print(method, "".join(("%s=%s " % kv) for kv in env.items()) + " ".join(cmd_args))
if not trap:
ret = getattr(subprocess, method)(cmd_args, **kwargs)
@@ -169,3 +174,11 @@ def create_syslog_handler():
handler = logging.handlers.SysLogHandler(address='/dev/log')
handler.setLevel(logging.WARNING)
return handler
+
+def create_file_with_mode(path, mode):
+ # Based on answer by A-B-B: http://stackoverflow.com/a/15015748
+ old_umask = os.umask(0)
+ try:
+ return os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT, mode), 'w')
+ finally:
+ os.umask(old_umask)
diff --git a/setup/mailpile.sh b/setup/mailpile.sh
new file mode 100755
index 00000000..7b207afd
--- /dev/null
+++ b/setup/mailpile.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+# Install Mailpile (https://www.mailpile.is/), a new
+# modern webmail client currently in alpha.
+
+source setup/functions.sh # load our functions
+source /etc/mailinabox.conf # load global vars
+
+# Dependencies, via their Makefile's debian-dev target and things they
+# should have also mentioned.
+
+apt_install python-imaging python-lxml python-jinja2 pep8 \
+ ruby-dev yui-compressor python-nose spambayes \
+ phantomjs python-pip python-mock python-pgpdump
+pip install 'selenium>=2.40.0'
+gem install therubyracer less
+
+# Install Mailpile
+
+# TODO: Install from a release.
+if [ ! -d externals/Mailpile ]; then
+ mkdir -p externals
+ git clone https://github.com/pagekite/Mailpile.git externals/Mailpile
+fi