Mailpile: an installation script and a multiplexer daemon that proxies Mailpile behind an authorization step and multiplexes Mailpile instances per logged in user
This commit is contained in:
parent
d11ce688ca
commit
4f57c62681
|
@ -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 "<html><body></body></html>"
|
||||
|
||||
# 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 """
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
top.location.reload();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@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/<path:path>', 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)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Mailpile</title>
|
||||
</head>
|
||||
<FRAMESET ROWS="35, *" BORDER="0">
|
||||
<FRAME SRC="status">
|
||||
<FRAME SRC="mailpile">
|
||||
</FRAMESET>
|
||||
</HTML>
|
|
@ -0,0 +1,57 @@
|
|||
<!DOCTYPE html>
|
||||
<html class="no-js">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>{{hostname}}</title>
|
||||
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-2 col-sm-8 col-md-offset-3 col-md-6 col-lg-offset-3 col-lg-6">
|
||||
<center>
|
||||
<h1 style="margin: 1em">{{hostname}}</h1>
|
||||
<p style="margin: 2em">Log in here to your Mailpile webmail.</p>
|
||||
{% if error %}
|
||||
<p style="margin: 2em" class="text-danger">{{error}}</p>
|
||||
{% endif %}
|
||||
</center>
|
||||
|
||||
<form class="form-horizontal" role="form" method="post">
|
||||
<div class="form-group">
|
||||
<label for="inputEmail3" class="col-sm-2 control-label">Email</label>
|
||||
<div class="col-sm-10">
|
||||
<input name="email" type="email" class="form-control" id="inputEmail" placeholder="Email" value="{{email}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inputPassword3" class="col-sm-2 control-label">Password</label>
|
||||
<div class="col-sm-10">
|
||||
<input name="password" type="password" class="form-control" placeholder="Password" value="{{password}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input name='remember' type="checkbox" {% if remember %}checked{% endif %}> Remember me
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-default">Sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="//code.jquery.com/jquery-2.1.1.min.js"> </script>
|
||||
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,24 @@
|
|||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding-top: 8px;
|
||||
padding-right: 8px;
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
background: #f6f6f6;
|
||||
color: #444;
|
||||
border-bottom: 1px solid #EEE;
|
||||
text-align: right;
|
||||
}
|
||||
body a {
|
||||
color: #449;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
<body>
|
||||
You are logged in as {{auth.email}}.
|
||||
<a href="logout">Log out?</a>
|
||||
</body>
|
||||
</html>
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue