diff --git a/README.md b/README.md index 3599d378..62b04819 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,7 @@ In short, it's like this: cd mailinabox sudo setup/start.sh -Then run the post-install checklist command to see what you need to do next: - - sudo management/whats_next.py - -Congratulations! You should now have a working setup. Feel free to login with your mail credentials created earlier in the setup +Congratulations! You should now have a working setup. You'll be given the address of the administrative interface for further instructions. **Status**: This is a work in progress. It works for what it is, but it is missing such things as quotas, backup/restore, etc. diff --git a/conf/nginx.conf b/conf/nginx.conf index 4f343c5a..a36e4eeb 100644 --- a/conf/nginx.conf +++ b/conf/nginx.conf @@ -24,6 +24,12 @@ server { root $ROOT; index index.html index.htm; + # Control Panel + rewrite ^/admin$ /admin/; + location /admin/ { + proxy_pass http://localhost:10222/; + } + # Roundcube Webmail configuration. rewrite ^/mail$ /mail/ redirect; rewrite ^/mail/$ /mail/index.php; diff --git a/management/auth.py b/management/auth.py index 01b2f8c0..dee8c7e5 100644 --- a/management/auth.py +++ b/management/auth.py @@ -2,6 +2,9 @@ import base64, os, os.path from flask import make_response +import utils +from mailconfig import get_mail_user_privileges + DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' @@ -37,37 +40,69 @@ class KeyAuthService: with create_file_with_mode(self.key_path, 0o640) as key_file: key_file.write(self.key + '\n') - def is_authenticated(self, request): - """Test if the client key passed in HTTP header matches the service key""" + def is_authenticated(self, request, env): + """Test if the client key passed in HTTP Authorization header matches the service key + or if the or username/password passed in the header matches an administrator user. + Returns 'OK' if the key is good or the user is an administrator, otherwise an error message.""" def decode(s): - return base64.b64decode(s.encode('utf-8')).decode('ascii') - - def parse_api_key(header): - if header is None: - return + return base64.b64decode(s.encode('ascii')).decode('ascii') + def parse_basic_auth(header): if " " not in header: - return + return None, None scheme, credentials = header.split(maxsplit=1) if scheme != 'Basic': - return + return None, None credentials = decode(credentials) if ":" not in credentials: - return + return None, None username, password = credentials.split(':', maxsplit=1) - return username + return username, password - request_key = parse_api_key(request.headers.get('Authorization')) + header = request.headers.get('Authorization') + if not header: + return "No authorization header provided." - return request_key == self.key + username, password = parse_basic_auth(header) - def make_unauthorized_response(self): - return make_response( - 'You must pass the API key from "{0}" as the username\n'.format(self.key_path), - 401, - { 'WWW-Authenticate': 'Basic realm="{0}"'.format(self.auth_realm) }) + if username in (None, ""): + return "Authorization header invalid." + elif username == self.key: + return "OK" + else: + return self.check_imap_login( username, password, env) + + def check_imap_login(self, email, pw, env): + # Validate a user's credentials. + + # Sanity check. + if email == "" or pw == "": + return "Enter an email address and password." + + # Authenticate. + try: + # Use doveadm to check credentials. doveadm will return + # a non-zero exit status if the credentials are no good, + # and check_call will raise an exception in that case. + utils.shell('check_call', [ + "/usr/bin/doveadm", + "auth", "test", + email, pw + ]) + except: + # Login failed. + return "Invalid email address or password." + + # Authorize. + # (This call should never fail on a valid user.) + privs = get_mail_user_privileges(email, env) + if isinstance(privs, tuple): raise Exception("Error getting privileges.") + if "admin" not in privs: + return "You are not an administrator for this system." + + return "OK" def _generate_key(self): raw_key = os.urandom(32) diff --git a/management/daemon.py b/management/daemon.py index a6be4335..32fbd3c9 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -2,11 +2,12 @@ import os, os.path, re, json +from functools import wraps + from flask import Flask, request, render_template, abort, Response -app = Flask(__name__) import auth, utils -from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user +from mailconfig import get_mail_users, add_mail_user, set_mail_password, remove_mail_user, get_archived_mail_users from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege from mailconfig import get_mail_aliases, get_mail_domains, add_mail_alias, remove_mail_alias @@ -14,76 +15,156 @@ env = utils.load_environment() auth_service = auth.KeyAuthService() -@app.before_request -def require_auth_key(): - if not auth_service.is_authenticated(request): - abort(401) +# We may deploy via a symbolic link, which confuses flask's template finding. +me = __file__ +try: + me = os.readlink(__file__) +except OSError: + pass + +app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates"))) + +# Decorator to protect views that require authentication. +def authorized_personnel_only(viewfunc): + @wraps(viewfunc) + def newview(*args, **kwargs): + # Check if the user is authorized. + authorized_status = auth_service.is_authenticated(request, env) + if authorized_status == "OK": + # Authorized. Call view func. + return viewfunc(*args, **kwargs) + + # Not authorized. Return a 401 (send auth) and a prompt to authorize by default. + status = 401 + headers = { 'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm) } + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + # Don't issue a 401 to an AJAX request because the user will + # be prompted for credentials, which is not helpful. + status = 403 + headers = None + + if request.headers.get('Accept') in (None, "", "*/*"): + # Return plain text output. + return Response(authorized_status+"\n", status=status, mimetype='text/plain', headers=headers) + else: + # Return JSON output. + return Response(json.dumps({ + "status": "error", + "reason": authorized_status + }+"\n"), status=status, mimetype='application/json', headers=headers) + + return newview @app.errorhandler(401) def unauthorized(error): return auth_service.make_unauthorized_response() +def json_response(data): + return Response(json.dumps(data), status=200, mimetype='application/json') + +################################### + +# Control Panel (unauthenticated views) + @app.route('/') def index(): - return render_template('index.html') + # Render the control panel. This route does not require user authentication + # so it must be safe! + return render_template('index.html', + hostname=env['PRIMARY_HOSTNAME'], + ) + +@app.route('/me') +def me(): + # Is the caller authorized? + authorized_status = auth_service.is_authenticated(request, env) + if authorized_status != "OK": + return json_response({ + "status": "not-authorized", + "reason": authorized_status, + }) + return json_response({ + "status": "authorized", + "api_key": auth_service.key, + }) # MAIL @app.route('/mail/users') +@authorized_personnel_only def mail_users(): if request.args.get("format", "") == "json": - users = get_mail_users(env, as_json=True) - return Response(json.dumps(users), status=200, mimetype='application/json') + return json_response(get_mail_users(env, as_json=True) + get_archived_mail_users(env)) else: return "".join(x+"\n" for x in get_mail_users(env)) @app.route('/mail/users/add', methods=['POST']) +@authorized_personnel_only def mail_users_add(): - return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), env) + return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), env) @app.route('/mail/users/password', methods=['POST']) +@authorized_personnel_only def mail_users_password(): return set_mail_password(request.form.get('email', ''), request.form.get('password', ''), env) @app.route('/mail/users/remove', methods=['POST']) +@authorized_personnel_only def mail_users_remove(): return remove_mail_user(request.form.get('email', ''), env) @app.route('/mail/users/privileges') +@authorized_personnel_only def mail_user_privs(): privs = get_mail_user_privileges(request.args.get('email', ''), env) if isinstance(privs, tuple): return privs # error return "\n".join(privs) @app.route('/mail/users/privileges/add', methods=['POST']) +@authorized_personnel_only def mail_user_privs_add(): return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "add", env) @app.route('/mail/users/privileges/remove', methods=['POST']) +@authorized_personnel_only def mail_user_privs_remove(): return add_remove_mail_user_privilege(request.form.get('email', ''), request.form.get('privilege', ''), "remove", env) @app.route('/mail/aliases') +@authorized_personnel_only def mail_aliases(): - return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env)) + if request.args.get("format", "") == "json": + return json_response(get_mail_aliases(env, as_json=True)) + else: + return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env)) @app.route('/mail/aliases/add', methods=['POST']) +@authorized_personnel_only def mail_aliases_add(): - return add_mail_alias(request.form.get('source', ''), request.form.get('destination', ''), env) + return add_mail_alias( + request.form.get('source', ''), + request.form.get('destination', ''), + env, + update_if_exists=(request.form.get('update_if_exists', '') == '1') + ) @app.route('/mail/aliases/remove', methods=['POST']) +@authorized_personnel_only def mail_aliases_remove(): return remove_mail_alias(request.form.get('source', ''), env) @app.route('/mail/domains') +@authorized_personnel_only def mail_domains(): return "".join(x+"\n" for x in get_mail_domains(env)) # DNS @app.route('/dns/update', methods=['POST']) +@authorized_personnel_only def dns_update(): from dns_update import do_dns_update try: @@ -91,24 +172,43 @@ def dns_update(): except Exception as e: return (str(e), 500) -@app.route('/dns/ds') -def dns_get_ds_records(): - from dns_update import get_ds_records - try: - return get_ds_records(env).replace("\t", " ") # tabs confuse godaddy - except Exception as e: - return (str(e), 500) +@app.route('/dns/dump') +@authorized_personnel_only +def dns_get_dump(): + from dns_update import build_recommended_dns + return json_response(build_recommended_dns(env)) # WEB @app.route('/web/update', methods=['POST']) +@authorized_personnel_only def web_update(): from web_update import do_web_update return do_web_update(env) # System +@app.route('/system/status', methods=["POST"]) +@authorized_personnel_only +def system_status(): + from whats_next import run_checks + class WebOutput: + def __init__(self): + self.items = [] + def add_heading(self, heading): + self.items.append({ "type": "heading", "text": heading, "extra": [] }) + def print_ok(self, message): + self.items.append({ "type": "ok", "text": message, "extra": [] }) + def print_error(self, message): + self.items.append({ "type": "error", "text": message, "extra": [] }) + def print_line(self, message, monospace=False): + self.items[-1]["extra"].append({ "text": message, "monospace": monospace }) + output = WebOutput() + run_checks(env, output) + return json_response(output.items) + @app.route('/system/updates') +@authorized_personnel_only def show_updates(): utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"]) simulated_install = utils.shell("check_output", ["/usr/bin/apt-get", "-qq", "-s", "upgrade"]) @@ -120,6 +220,7 @@ def show_updates(): return "\n".join(pkgs) @app.route('/system/update-packages', methods=["POST"]) +@authorized_personnel_only def do_updates(): utils.shell("check_call", ["/usr/bin/apt-get", "-qq", "update"]) return utils.shell("check_output", ["/usr/bin/apt-get", "-y", "upgrade"], env={ @@ -130,6 +231,7 @@ def do_updates(): if __name__ == '__main__': if "DEBUG" in os.environ: app.debug = True + if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"] if not app.debug: app.logger.addHandler(utils.create_syslog_handler()) diff --git a/management/dns_update.py b/management/dns_update.py index bc042e8c..24e12e35 100755 --- a/management/dns_update.py +++ b/management/dns_update.py @@ -160,11 +160,11 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True): records.append(("_25._tcp", "TLSA", build_tlsa_record(env), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used.")) # The MX record says where email for the domain should be delivered: Here! - records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname of the machine that handles @%s mail." % domain)) + records.append((None, "MX", "10 %s." % env["PRIMARY_HOSTNAME"], "Required. Specifies the hostname (and priority) of the machine that handles @%s mail." % domain)) # SPF record: Permit the box ('mx', see above) to send mail on behalf of # the domain, and no one else. - records.append((None, "TXT", '"v=spf1 mx -all"', "Recomended. Specifies that only the box is permitted to send @%s mail." % domain)) + records.append((None, "TXT", '"v=spf1 mx -all"', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain)) # Add DNS records for any subdomains of this domain. We should not have a zone for # both a domain and one of its subdomains. @@ -192,9 +192,9 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True): # Add defaults if not overridden by the user's custom settings. defaults = [ - (None, "A", env["PUBLIC_IP"], "Optional. Sets the IP address that %s resolves to, e.g. for web hosting." % domain), + (None, "A", env["PUBLIC_IP"], "Optional. Sets the IP address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain), ("www", "A", env["PUBLIC_IP"], "Optional. Sets the IP address that www.%s resolves to, e.g. for web hosting." % domain), - (None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting." % domain), + (None, "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that %s resolves to, e.g. for web hosting. (It is not necessary for receiving mail on this domain.)" % domain), ("www", "AAAA", env.get('PUBLIC_IPV6'), "Optional. Sets the IPv6 address that www.%s resolves to, e.g. for web hosting." % domain), ] for qname, rtype, value, explanation in defaults: @@ -209,7 +209,7 @@ def build_zone(domain, all_domains, additional_records, env, is_zone=True): # Append the DKIM TXT record to the zone as generated by OpenDKIM, after string formatting above. with open(opendkim_record_file) as orf: m = re.match(r"(\S+)\s+IN\s+TXT\s+(\(.*\))\s*;", orf.read(), re.S) - records.append((m.group(1), "TXT", m.group(2), "Recommended. Specifies that only the box is permitted to send mail at this domain.")) + records.append((m.group(1), "TXT", m.group(2), "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain)) # Append a DMARC record. records.append(("_dmarc", "TXT", '"v=DMARC1; p=quarantine"', "Optional. Specifies that mail that does not originate from the box but claims to be from @%s is suspect and should be quarantined by the recipient's mail system." % domain)) @@ -496,19 +496,6 @@ def sign_zone(domain, zonefile, env): # Remove our temporary file. for fn in files_to_kill: os.unlink(fn) - -######################################################################## - -def get_ds_records(env): - zonefiles = get_dns_zones(env) - ret = "" - for domain, zonefile in zonefiles: - fn = "/etc/nsd/zones/" + zonefile + ".ds" - if os.path.exists(fn): - with open(fn, "r") as fr: - ret += fr.read().strip() + "\n" - return ret - ######################################################################## @@ -605,9 +592,8 @@ def justtestingdotemail(domain, records): ######################################################################## -if __name__ == "__main__": - from utils import load_environment - env = load_environment() +def build_recommended_dns(env): + ret = [] domains = get_dns_domains(env) zonefiles = get_dns_zones(env) for domain, zonefile in zonefiles: @@ -616,15 +602,32 @@ if __name__ == "__main__": # remove records that we don't dislay records = [r for r in records if r[3] is not False] - # put Required at the top + # put Required at the top, then Recommended, then everythiing else records.sort(key = lambda r : 0 if r[3].startswith("Required.") else (1 if r[3].startswith("Recommended.") else 2)) - # print - for qname, rtype, value, explanation in records: - print("; " + explanation) - if qname == None: + # expand qnames + for i in range(len(records)): + if records[i][0] == None: qname = domain else: - qname = qname + "." + domain - print(qname, rtype, value) + qname = records[i][0] + "." + domain + + records[i] = { + "qname": qname, + "rtype": records[i][1], + "value": records[i][2], + "explanation": records[i][3], + } + + # return + ret.append((domain, records)) + return ret + +if __name__ == "__main__": + from utils import load_environment + env = load_environment() + for zone, records in build_recommended_dns(env): + for record in records: + print("; " + record['explanation']) + print(record['qname'], record['rtype'], record['value'], sep="\t") print() diff --git a/management/mailconfig.py b/management/mailconfig.py index 1ad7c57c..1eba302e 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -49,18 +49,80 @@ def open_database(env, with_connection=False): def get_mail_users(env, as_json=False): c = open_database(env) c.execute('SELECT email, privileges FROM users') + + # turn into a list of tuples, but sorted by domain & email address + users = { row[0]: row[1] for row in c.fetchall() } # make dict + users = [ (email, users[email]) for email in utils.sort_email_addresses(users.keys(), env) ] + if not as_json: - return [row[0] for row in c.fetchall()] + return [email for email, privileges in users] else: + aliases = get_mail_alias_map(env) return [ - { "email": row[0], "privileges": parse_privs(row[1]) } - for row in c.fetchall() + { + "email": email, + "privileges": parse_privs(privileges), + "status": "active", + "aliases": [ + (alias, sorted(evaluate_mail_alias_map(alias, aliases, env))) + for alias in aliases.get(email.lower(), []) + ] + } + for email, privileges in users ] -def get_mail_aliases(env): +def get_archived_mail_users(env): + real_users = set(get_mail_users(env)) + root = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes') + ret = [] + for domain_enc in os.listdir(root): + for user_enc in os.listdir(os.path.join(root, domain_enc)): + email = utils.unsafe_domain_name(user_enc) + "@" + utils.unsafe_domain_name(domain_enc) + if email in real_users: continue + ret.append({ + "email": email, + "privileges": "", + "status": "inactive" + }) + return ret + +def get_mail_aliases(env, as_json=False): c = open_database(env) c.execute('SELECT source, destination FROM aliases') - return [(row[0], row[1]) for row in c.fetchall()] + aliases = { row[0]: row[1] for row in c.fetchall() } # make dict + + # put in a canonical order: sort by domain, then by email address lexicographically + aliases = [ (source, aliases[source]) for source in utils.sort_email_addresses(aliases.keys(), env) ] # sort + + # but put automatic aliases to administrator@ last + aliases.sort(key = lambda x : x[1] == get_system_administrator(env)) + + if as_json: + required_aliases = get_required_aliases(env) + aliases = [ + { + "source": alias[0], + "destination": [d.strip() for d in alias[1].split(",")], + "required": alias[0] in required_aliases or alias[0] == get_system_administrator(env), + } + for alias in aliases + ] + return aliases + +def get_mail_alias_map(env): + aliases = { } + for alias, targets in get_mail_aliases(env): + for em in targets.split(","): + em = em.strip().lower() + aliases.setdefault(em, []).append(alias) + return aliases + +def evaluate_mail_alias_map(email, aliases, env): + ret = set() + for alias in aliases.get(email.lower(), []): + ret.add(alias) + ret |= evaluate_mail_alias_map(alias, aliases, env) + return ret def get_mail_domains(env, filter_aliases=lambda alias : True): def get_domain(emailaddr): @@ -70,10 +132,30 @@ def get_mail_domains(env, filter_aliases=lambda alias : True): + [get_domain(source) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ] ) -def add_mail_user(email, pw, env): +def add_mail_user(email, pw, privs, env): + # validate email + if email.strip() == "": + return ("No email address provided.", 400) if not validate_email(email, mode='user'): return ("Invalid email address.", 400) + # validate password + if pw.strip() == "": + return ("No password provided.", 400) + if re.search(r"[\s]", pw): + return ("Passwords cannot contain spaces.", 400) + if len(pw) < 4: + return ("Passwords must be at least four characters.", 400) + + # validate privileges + if privs is None or privs.strip() == "": + privs = [] + else: + privs = privs.split("\n") + for p in privs: + validation = validate_privilege(p) + if validation: return validation + # get the database conn, c = open_database(env, with_connection=True) @@ -82,7 +164,8 @@ def add_mail_user(email, pw, env): # add the user to the database try: - c.execute("INSERT INTO users (email, password) VALUES (?, ?)", (email, pw)) + c.execute("INSERT INTO users (email, password, privileges) VALUES (?, ?, ?)", + (email, pw, "\n".join(privs))) except sqlite3.IntegrityError: return ("User already exists.", 400) @@ -142,13 +225,21 @@ def get_mail_user_privileges(email, env): return ("That's not a user (%s)." % email, 400) return parse_privs(rows[0][0]) -def add_remove_mail_user_privilege(email, priv, action, env): +def validate_privilege(priv): if "\n" in priv or priv.strip() == "": return ("That's not a valid privilege (%s)." % priv, 400) + return None +def add_remove_mail_user_privilege(email, priv, action, env): + # validate + validation = validate_privilege(priv) + if validation: return validation + + # get existing privs, but may fail privs = get_mail_user_privileges(email, env) if isinstance(privs, tuple): return privs # error + # update privs set if action == "add": if priv not in privs: privs.append(priv) @@ -157,6 +248,7 @@ def add_remove_mail_user_privilege(email, priv, action, env): else: return ("Invalid action.", 400) + # commit to database conn, c = open_database(env, with_connection=True) c.execute("UPDATE users SET privileges=? WHERE email=?", ("\n".join(privs), email)) if c.rowcount != 1: @@ -165,20 +257,42 @@ def add_remove_mail_user_privilege(email, priv, action, env): return "OK" -def add_mail_alias(source, destination, env, do_kick=True): +def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True): + # validate source + if source.strip() == "": + return ("No incoming email address provided.", 400) if not validate_email(source, mode='alias'): - return ("Invalid email address.", 400) + return ("Invalid incoming email address (%s)." % source, 400) + + # parse comma and \n-separated destination emails & validate + dests = [] + for line in destination.split("\n"): + for email in line.split(","): + email = email.strip() + if email == "": continue + if not validate_email(email, mode='alias'): + return ("Invalid destination email address (%s)." % email, 400) + dests.append(email) + if len(destination) == 0: + return ("No destination email address(es) provided.", 400) + destination = ",".join(dests) conn, c = open_database(env, with_connection=True) try: c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination)) + return_status = "alias added" except sqlite3.IntegrityError: - return ("Alias already exists (%s)." % source, 400) + if not update_if_exists: + return ("Alias already exists (%s)." % source, 400) + else: + c.execute("UPDATE aliases SET destination = ? WHERE source = ?", (destination, source)) + return_status = "alias updated" + conn.commit() if do_kick: # Update things in case any new domains are added. - return kick(env, "alias added") + return kick(env, return_status) def remove_mail_alias(source, env, do_kick=True): conn, c = open_database(env, with_connection=True) @@ -191,6 +305,35 @@ def remove_mail_alias(source, env, do_kick=True): # Update things in case any domains are removed. return kick(env, "alias removed") +def get_system_administrator(env): + return "administrator@" + env['PRIMARY_HOSTNAME'] + +def get_required_aliases(env): + # These are the aliases that must exist. + aliases = set() + + # The hostmaster aliase is exposed in the DNS SOA for each zone. + aliases.add("hostmaster@" + env['PRIMARY_HOSTNAME']) + + # Get a list of domains we serve mail for, except ones for which the only + # email on that domain is a postmaster/admin alias to the administrator. + real_mail_domains = get_mail_domains(env, + filter_aliases = lambda alias : \ + (not alias[0].startswith("postmaster@") \ + and not alias[0].startswith("admin@")) \ + or alias[1] != get_system_administrator(env) \ + ) + + # Create postmaster@ and admin@ for all domains we serve mail on. + # postmaster@ is assumed to exist by our Postfix configuration. admin@ + # isn't anything, but it might save the user some trouble e.g. when + # buying an SSL certificate. + for domain in real_mail_domains: + aliases.add("postmaster@" + domain) + aliases.add("admin@" + domain) + + return aliases + def kick(env, mail_result=None): results = [] @@ -199,50 +342,32 @@ def kick(env, mail_result=None): if mail_result is not None: results.append(mail_result + "\n") - # Create hostmaster@ for the primary domain if it does not already exist. - # Default the target to administrator@ which the user is responsible for - # setting and keeping up to date. + # Ensure every required alias exists. existing_aliases = get_mail_aliases(env) - - administrator = "administrator@" + env['PRIMARY_HOSTNAME'] + required_aliases = get_required_aliases(env) def ensure_admin_alias_exists(source): # Does this alias exists? for s, t in existing_aliases: if s == source: return - # Doesn't exist. + administrator = get_system_administrator(env) add_mail_alias(source, administrator, env, do_kick=False) results.append("added alias %s (=> %s)\n" % (source, administrator)) - ensure_admin_alias_exists("hostmaster@" + env['PRIMARY_HOSTNAME']) - # Get a list of domains we serve mail for, except ones for which the only - # email on that domain is a postmaster/admin alias to the administrator. + for alias in required_aliases: + ensure_admin_alias_exists(alias) - real_mail_domains = get_mail_domains(env, - filter_aliases = lambda alias : \ - (not alias[0].startswith("postmaster@") \ - and not alias[0].startswith("admin@")) \ - or alias[1] != administrator \ - ) - - # Create postmaster@ and admin@ for all domains we serve mail on. - # postmaster@ is assumed to exist by our Postfix configuration. admin@ - # isn't anything, but it might save the user some trouble e.g. when - # buying an SSL certificate. - for domain in real_mail_domains: - ensure_admin_alias_exists("postmaster@" + domain) - ensure_admin_alias_exists("admin@" + domain) - - # Remove auto-generated hostmaster/postmaster/admin on domains we no + # Remove auto-generated postmaster/admin on domains we no # longer have any other email addresses for. for source, target in existing_aliases: user, domain = source.split("@") - if user in ("postmaster", "admin") and domain not in real_mail_domains \ - and target == administrator: + if user in ("postmaster", "admin") \ + and source not in required_aliases \ + and target == get_system_administrator(env): remove_mail_alias(source, env, do_kick=False) results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (source, target)) diff --git a/management/templates/aliases.html b/management/templates/aliases.html new file mode 100644 index 00000000..571909fe --- /dev/null +++ b/management/templates/aliases.html @@ -0,0 +1,159 @@ + + +
Aliases are email forwarders. An alias can forward email to a mail user or to any email address.
+ + + ++ | Email Address |
+ Forwards To | +
---|
Hostmaster@, postmaster@, and admin@ email addresses are required on some domains.
+ + + + + \ No newline at end of file diff --git a/management/templates/index.html b/management/templates/index.html index 925201e3..def1a387 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -1,11 +1,341 @@ - - - -Use this server to issue commands to the Mail-in-a-Box management daemon.
- - \ No newline at end of file +Log in here for your Mail-in-a-Box control panel.
+You can access your email using webmail, desktop mail clients, or mobile apps.
+ +Here is what you need to know for webmail:
+ + + +Webmail Address: | https://{{hostname}}/mail |
---|---|
Username: | Your whole email address. |
Password: | Your mail password. |
On mobile devices you might need to install a “mail client” app. We recommend K-9 Mail. On a desktop you could try Mozilla Thunderbird.
+ +Configure your device or desktop mail client as follows:
+ +Server Name: | {{hostname}} |
---|---|
Username: | Your whole email address. |
Password: | Your mail password. |
Protocol | Port | Options |
---|---|---|
IMAP | 993 | SSL |
SMTP | 587 | STARTTLS |
Exchange ActiveSync | n/a | Secure Connection |
Depending on your mail program, you will use either IMAP & SMTP or Exchange ActiveSync. See this list of compatible devices for Exchange ActiveSync.
+ +Mail-in-a-Box uses greylisting to cut down on spam. The first time you receive an email from a recipient, it may be delayed for ten minutes.
+This is for advanced configurations.
+ +Although your box is configured to serve its own DNS, it is possible to host your DNS elsewhere. We do not recommend this.
+ +If you do so, you are responsible for keeping your DNS entries up to date. In particular DNSSEC entries must be re-signed periodically. Do not set a DS record at your registrar or publish DNSSEC entries in your DNS zones if you do not intend to keep them up to date.
+ +Enter the following DNS entries at your DNS provider:
+ +QName | +Type | +Value | +
---|
Add an email address to this system. This will create a new login username/password. (Use aliases to create email addresses that forward to existing accounts.)
+ + ++ Passwords must be at least four characters and may not contain spaces. + Administrators get access to this control panel. +
+ ++ | Email Address (Also the user’s login username.) |
+ Privileges | +
---|