#!/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)