From 3fdfad27cdffd5e84882ef1f058112c24fae3885 Mon Sep 17 00:00:00 2001 From: David Piggott Date: Sat, 27 Jun 2015 18:23:15 +0100 Subject: [PATCH 1/5] Add support for bidirectional mail alias controls This is an extension of #427. Building on that change it adds support in the aliases table for flagging aliases as: 1. Applicable to inbound and outbound mail. 2. Applicable to inbound mail only. 3. Applicable to outbound mail only. 4. Disabled. The aliases UI is also updated to allow administrators to set the direction of each alias. Using this extra information, the sqlite queries executed by Postfix are updated so only the relevant alias types are checked. The goal and result of this change is that outbound-only catch-all aliases can now be defined (in fact catch-all aliases of any type can be defined). This allow us to continue supporting relaying as described at https://mailinabox.email/advanced-configuration.html#relay without requiring that administrators either create regular aliases for each outbound *relay* address, or that they create a catch-all alias and then face a flood of spam. I have tested the code as it is in this commit and fixed every issue I found, so in that regard the change is complete. However I see room for improvement in terms of updating terminology to make the UI etc. easier to understand. I'll make those changes as subsequent commits so that this tested checkpoint is not lost, but also so they can be rejected independently of the actual change if not wanted. --- management/daemon.py | 8 ++-- management/mailconfig.py | 32 +++++++++------- management/status_checks.py | 25 +++++++------ management/templates/aliases.html | 61 +++++++++++++++++++++++++------ setup/mail-users.sh | 20 +++++----- setup/migrate.py | 5 +++ 6 files changed, 102 insertions(+), 49 deletions(-) diff --git a/management/daemon.py b/management/daemon.py index af15b1c3..1b374c18 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -45,7 +45,7 @@ def authorized_personnel_only(viewfunc): # Authorized to access an API view? if "admin" in privs: - # Call view func. + # Call view func. return viewfunc(*args, **kwargs) elif not error: error = "You are not an administrator." @@ -179,7 +179,7 @@ def mail_aliases(): if request.args.get("format", "") == "json": return json_response(get_mail_aliases_ex(env)) else: - return "".join(x+"\t"+y+"\n" for x, y in get_mail_aliases(env)) + return "".join(source+"\t"+destination+"\t"+applies_inbound+"\t"+applies_outbound+"\n" for source, destination, applies_inbound, applies_outbound in get_mail_aliases(env)) @app.route('/mail/aliases/add', methods=['POST']) @authorized_personnel_only @@ -187,6 +187,8 @@ def mail_aliases_add(): return add_mail_alias( request.form.get('source', ''), request.form.get('destination', ''), + request.form.get('applies_inbound', '') == '1', + request.form.get('applies_outbound', '') == '1', env, update_if_exists=(request.form.get('update_if_exists', '') == '1') ) @@ -283,7 +285,7 @@ def dns_set_record(qname, rtype="A"): # make this action set (replace all records for this # qname-rtype pair) rather than add (add a new record). action = "set" - + elif request.method == "DELETE": if value == '': # Delete all records for this qname-type pair. diff --git a/management/mailconfig.py b/management/mailconfig.py index 34cc6761..c5ff34e3 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -181,13 +181,13 @@ def get_admins(env): return users def get_mail_aliases(env): - # Returns a sorted list of tuples of (alias, forward-to string). + # Returns a sorted list of tuples of (alias, forward-to string, applies-to-inbound-mail, applies-to-outbound-mail). c = open_database(env) - c.execute('SELECT source, destination FROM aliases') - aliases = { row[0]: row[1] for row in c.fetchall() } # make dict + c.execute('SELECT source, destination, applies_inbound, applies_outbound FROM aliases') + aliases = { row[0]: row[1:4] 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) ] + aliases = [ (source,) + aliases[source] for source in utils.sort_email_addresses(aliases.keys(), env) ] return aliases def get_mail_aliases_ex(env): @@ -202,6 +202,8 @@ def get_mail_aliases_ex(env): # source: "name@domain.tld", # IDNA-encoded # source_display: "name@domain.tld", # full Unicode # destination: ["target1@domain.com", "target2@domain.com", ...], + # applies_inbound: True|False + # applies_outbound: True|False # required: True|False # }, # ... @@ -212,7 +214,7 @@ def get_mail_aliases_ex(env): required_aliases = get_required_aliases(env) domains = {} - for source, destination in get_mail_aliases(env): + for source, destination, applies_inbound, applies_outbound in get_mail_aliases(env): # get alias info domain = get_domain(source) required = (source in required_aliases) @@ -227,6 +229,8 @@ def get_mail_aliases_ex(env): "source": source, "source_display": prettify_idn_email_address(source), "destination": [prettify_idn_email_address(d.strip()) for d in destination.split(",")], + "applies_inbound": True if applies_inbound == 1 else False, + "applies_outbound": True if applies_outbound == 1 else False, "required": required, }) @@ -250,7 +254,7 @@ def get_mail_domains(env, filter_aliases=lambda alias : True): # configured on the system. return set( [get_domain(addr, as_unicode=False) for addr in get_mail_users(env)] - + [get_domain(source, as_unicode=False) for source, target in get_mail_aliases(env) if filter_aliases((source, target)) ] + + [get_domain(source, as_unicode=False) for source, *_ in get_mail_aliases(env) if filter_aliases(source) ] ) def add_mail_user(email, pw, privs, env): @@ -406,7 +410,7 @@ def add_remove_mail_user_privilege(email, priv, action, env): return "OK" -def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True): +def add_mail_alias(source, destination, applies_inbound, applies_outbound, env, update_if_exists=False, do_kick=True): # convert Unicode domain to IDNA source = sanitize_idn_email_address(source) @@ -460,13 +464,13 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru # save to db conn, c = open_database(env, with_connection=True) try: - c.execute("INSERT INTO aliases (source, destination) VALUES (?, ?)", (source, destination)) + c.execute("INSERT INTO aliases (source, destination, applies_inbound, applies_outbound) VALUES (?, ?, ?, ?)", (source, destination, 1 if applies_inbound else 0, 1 if applies_outbound else 0)) return_status = "alias added" except sqlite3.IntegrityError: if not update_if_exists: return ("Alias already exists (%s)." % source, 400) else: - c.execute("UPDATE aliases SET destination = ? WHERE source = ?", (destination, source)) + c.execute("UPDATE aliases SET destination = ?, applies_inbound = ?, applies_outbound = ? WHERE source = ?", (destination, 1 if applies_inbound else 0, 1 if applies_outbound else 0, source)) return_status = "alias updated" conn.commit() @@ -507,8 +511,8 @@ def get_required_aliases(env): # email on that domain are the required aliases or a catch-all/domain-forwarder. real_mail_domains = get_mail_domains(env, filter_aliases = lambda alias : - not alias[0].startswith("postmaster@") and not alias[0].startswith("admin@") - and not alias[0].startswith("@") + not alias.startswith("postmaster@") and not alias.startswith("admin@") + and not alias.startswith("@") ) # Create postmaster@ and admin@ for all domains we serve mail on. @@ -541,14 +545,14 @@ def kick(env, mail_result=None): return # Does this alias exists? - for s, t in existing_aliases: + for s, *_ in existing_aliases: if s == source: return # Doesn't exist. administrator = get_system_administrator(env) if source == administrator: return # don't make an alias from the administrator to itself --- this alias must be created manually - add_mail_alias(source, administrator, env, do_kick=False) + add_mail_alias(source, administrator, True, True, env, do_kick=False) results.append("added alias %s (=> %s)\n" % (source, administrator)) for alias in required_aliases: @@ -556,7 +560,7 @@ def kick(env, mail_result=None): # Remove auto-generated postmaster/admin on domains we no # longer have any other email addresses for. - for source, target in existing_aliases: + for source, target, *_ in existing_aliases: user, domain = source.split("@") if user in ("postmaster", "admin") \ and source not in required_aliases \ diff --git a/management/status_checks.py b/management/status_checks.py index 7c89b30f..2825654c 100755 --- a/management/status_checks.py +++ b/management/status_checks.py @@ -33,7 +33,7 @@ def run_checks(rounded_values, env, output, pool): # (ignore errors; if bind9/rndc isn't running we'd already report # that in run_services checks.) shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True) - + run_system_checks(rounded_values, env, output) # perform other checks asynchronously @@ -264,10 +264,10 @@ def run_domain_checks_on_domain(domain, rounded_time, env, dns_domains, dns_zone if domain == env["PRIMARY_HOSTNAME"]: check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles) - + if domain in dns_domains: check_dns_zone(domain, env, output, dns_zonefiles) - + if domain in mail_domains: check_mail_domain(domain, env, output) @@ -351,11 +351,14 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles): check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output) def check_alias_exists(alias_name, alias, env, output): - mail_alises = dict(get_mail_aliases(env)) - if alias in mail_alises: - output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_alises[alias])) + mail_aliases = dict([(source, (destination, applies_inbound)) for source, destination, applies_inbound, *_ in get_mail_aliases(env)]) + if alias in mail_aliases: + if mail_aliases[alias][1]: + output.print_ok("%s exists as an inbound mail alias. [%s ↦ %s]" % (alias_name, alias, mail_aliases[alias][0])) + else: + output.print_error("%s exists as a mail alias [%s ↦ %s] but is not enabled for inbound email." % (alias_name, alias, mail_aliases[alias][0])) else: - output.print_error("""You must add a mail alias for %s and direct email to you or another administrator.""" % alias) + output.print_error("""You must add an inbound mail alias for %s which directs email to you or another administrator.""" % alias) def check_dns_zone(domain, env, output, dns_zonefiles): # If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query. @@ -492,7 +495,7 @@ def check_mail_domain(domain, env, output): # Check that the postmaster@ email address exists. Not required if the domain has a # catch-all address or domain alias. - if "@" + domain not in dict(get_mail_aliases(env)): + if "@" + domain not in [source for source, *_ in get_mail_aliases(env)]: check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output) # Stop if the domain is listed in the Spamhaus Domain Block List. @@ -884,7 +887,7 @@ def run_and_output_changes(env, pool, send_via_email): if category not in cur_status: out.add_heading(category) out.print_warning("This section was removed.") - + if send_via_email: # If there were changes, send off an email. buf = out.buf.getvalue() @@ -896,7 +899,7 @@ def run_and_output_changes(env, pool, send_via_email): msg['To'] = "administrator@%s" % env['PRIMARY_HOSTNAME'] msg['Subject'] = "[%s] Status Checks Change Notice" % env['PRIMARY_HOSTNAME'] msg.set_payload(buf, "UTF-8") - + # send to administrator@ import smtplib mailserver = smtplib.SMTP('localhost', 25) @@ -906,7 +909,7 @@ def run_and_output_changes(env, pool, send_via_email): "administrator@%s" % env['PRIMARY_HOSTNAME'], # RCPT TO msg.as_string()) mailserver.quit() - + # Store the current status checks output for next time. os.makedirs(os.path.dirname(cache_fn), exist_ok=True) with open(cache_fn, "w") as f: diff --git a/management/templates/aliases.html b/management/templates/aliases.html index 9336672b..dceaac57 100644 --- a/management/templates/aliases.html +++ b/management/templates/aliases.html @@ -13,7 +13,7 @@
- +
@@ -30,6 +30,17 @@
You may use international (non-ASCII) characters for the domain part of the email address only.
+
+ +
+ +
+
@@ -50,6 +61,7 @@ Alias
+ Direction Forwards To @@ -71,6 +83,7 @@ + @@ -100,6 +113,19 @@ function show_aliases() { if (alias.required) n.addClass('alias-required'); n.attr('data-email', alias.source_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend n.find('td.email').text(alias.source_display) + if (!alias.applies_inbound && !alias.applies_outbound) { + n.find('td.direction').text('') + n.attr('data-direction', 'disabled'); + } else if (!alias.applies_inbound && alias.applies_outbound) { + n.find('td.direction').text('↤') + n.attr('data-direction', 'outbound'); + } else if (alias.applies_inbound && !alias.applies_outbound) { + n.find('td.direction').text('↦') + n.attr('data-direction', 'inbound'); + } else if (alias.applies_inbound && alias.applies_outbound) { + n.find('td.direction').text('↮') + n.attr('data-direction', 'bidirectional'); + } for (var j = 0; j < alias.destination.length; j++) n.find('td.target').append($("
").text(alias.destination[j])) $('#alias_table tbody').append(n); @@ -114,18 +140,21 @@ function show_aliases() { if ($(this).attr('data-mode') == "regular") { $('#addaliasEmail').attr('type', 'email'); $('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)'); - $('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); + $('#addaliasDirection').val('bidirectional'); + $('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); $('#alias_mode_info').slideUp(); } else if ($(this).attr('data-mode') == "catchall") { $('#addaliasEmail').attr('type', 'text'); $('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)'); - $('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); + $('#addaliasDirection').val('outbound'); + $('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); $('#alias_mode_info').slideDown(); $('#alias_mode_info span').addClass('hidden'); $('#alias_mode_info span.catchall').removeClass('hidden'); } else if ($(this).attr('data-mode') == "domainalias") { $('#addaliasEmail').attr('type', 'text'); $('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)'); + $('#addaliasDirection').val('inbound'); $('#addaliasTargets').attr('placeholder', 'forward to domain (@yourdomain.com)'); $('#alias_mode_info').slideDown(); $('#alias_mode_info span').addClass('hidden'); @@ -140,6 +169,7 @@ var is_alias_add_update = false; function do_add_alias() { var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias"; var email = $("#addaliasEmail").val(); + var direction = $("#addaliasDirection").val(); var targets = $("#addaliasTargets").val(); api( "/mail/aliases/add", @@ -147,7 +177,9 @@ function do_add_alias() { { update_if_exists: is_alias_add_update ? '1' : '0', source: email, - destination: targets + destination: targets, + applies_inbound: (direction == 'bidirectional' || direction == 'inbound') ? '1' : '0', + applies_outbound: (direction == 'bidirectional' || direction == 'outbound') ? '1' : '0' }, function(r) { // Responses are multiple lines of pre-formatted text. @@ -164,6 +196,12 @@ function do_add_alias() { function aliases_reset_form() { $("#addaliasEmail").prop('disabled', false); $("#addaliasEmail").val('') + if ($('#alias_type_buttons button').attr('data-mode') == "regular") + $('#addaliasDirection').val('bidirectional'); + else if ($('#alias_type_buttons button').attr('data-mode') == "catchall") + $('#alias_type_buttons').val('outbound'); + else if ($('#addaliasDirection button').attr('data-mode') == "domainalias") + $('#addaliasDirection').val('inbound'); $("#addaliasTargets").val('') $('#alias-cancel').addClass('hidden'); $('#add-alias-button').text('Add Alias'); @@ -176,20 +214,21 @@ function aliases_edit(elem) { var targets = ""; for (var i = 0; i < targetdivs.length; i++) targets += $(targetdivs[i]).text() + "\n"; - - is_alias_add_update = true; - $('#alias-cancel').removeClass('hidden'); - $("#addaliasEmail").prop('disabled', true); - $("#addaliasEmail").val(email); - $("#addaliasTargets").val(targets); - $('#add-alias-button').text('Update'); + var direction = $(elem).parents('tr').attr('data-direction') if (email.charAt(0) == '@' && targets.charAt(0) == '@') $('#alias_type_buttons button[data-mode="domainalias"]').click(); else if (email.charAt(0) == '@') $('#alias_type_buttons button[data-mode="catchall"]').click(); else $('#alias_type_buttons button[data-mode="regular"]').click(); + $('#alias-cancel').removeClass('hidden'); + $("#addaliasEmail").prop('disabled', true); + $("#addaliasEmail").val(email); + $('#addaliasDirection').val(direction); + $("#addaliasTargets").val(targets); + $('#add-alias-button').text('Update'); $('body').animate({ scrollTop: 0 }) + is_alias_add_update = true; } function aliases_remove(elem) { diff --git a/setup/mail-users.sh b/setup/mail-users.sh index 29e3f08e..fadbdd8d 100755 --- a/setup/mail-users.sh +++ b/setup/mail-users.sh @@ -5,7 +5,7 @@ # # This script configures user authentication for Dovecot # and Postfix (which relies on Dovecot) and destination -# validation by quering an Sqlite3 database of mail users. +# validation by quering an Sqlite3 database of mail users. source setup/functions.sh # load our functions source /etc/mailinabox.conf # load global vars @@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite if [ ! -f $db_path ]; then echo Creating new user database: $db_path; echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path; - echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL);" | sqlite3 $db_path; + echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, applies_inbound INTEGER NOT NULL DEFAULT 1, applies_outbound INTEGER NOT NULL DEFAULT 1);" | sqlite3 $db_path; fi # ### User Authentication @@ -72,17 +72,17 @@ tools/editconf.py /etc/postfix/main.cf \ # ### Sender Validation # Use a Sqlite3 database to set login maps. This is used with -# reject_authenticated_sender_login_mismatch to see if user is -# allowed to send mail using FROM field specified in the request. +# reject_authenticated_sender_login_mismatch to see if the user is +# allowed to send mail as the FROM address specified in the request. tools/editconf.py /etc/postfix/main.cf \ smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf -# SQL statement to set login map which includes the case when user is -# sending email using a valid alias. -# This is the same as virtual-alias-maps.cf, See below +# SQL statement that returns a list of addresses/domains the logged in username +# is allowed to send as. This is similar to virtual-alias-maps.cf (see below). +# Matches from the users table take priority over (direct) aliases. cat > /etc/postfix/sender-login-maps.cf << EOF; dbpath=$db_path -query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; +query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND applies_outbound=1 UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; EOF # ### Destination Validation @@ -98,7 +98,7 @@ tools/editconf.py /etc/postfix/main.cf \ # SQL statement to check if we handle mail for a domain, either for users or aliases. cat > /etc/postfix/virtual-mailbox-domains.cf << EOF; dbpath=$db_path -query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s' +query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s' AND applies_inbound=1 EOF # SQL statement to check if we handle mail for a user. @@ -129,7 +129,7 @@ EOF # postfix's preference for aliases for whole email addresses. cat > /etc/postfix/virtual-alias-maps.cf << EOF; dbpath=$db_path -query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; +query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND applies_inbound=1 UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; EOF # Restart Services diff --git a/setup/migrate.py b/setup/migrate.py index fc1877e4..e181cec4 100755 --- a/setup/migrate.py +++ b/setup/migrate.py @@ -101,6 +101,11 @@ def migration_8(env): # a new key, which will be 2048 bits. os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')) +def migration_9(env): + db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite') + shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD COLUMN applies_inbound INTEGER NOT NULL DEFAULT 1"]) + shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD COLUMN applies_outbound INTEGER NOT NULL DEFAULT 1"]) + def get_current_migration(): ver = 0 while True: From e6ff28098432e276068a83c687017bc7244ffea8 Mon Sep 17 00:00:00 2001 From: David Piggott Date: Sat, 4 Jul 2015 16:31:11 +0100 Subject: [PATCH 2/5] Store and set alias receivers and senders separately for maximum control --- management/daemon.py | 9 +- management/mailconfig.py | 149 ++++++++++++++++-------------- management/status_checks.py | 14 +-- management/templates/aliases.html | 101 ++++++++------------ setup/mail-users.sh | 8 +- setup/migrate.py | 51 +++++++++- 6 files changed, 186 insertions(+), 146 deletions(-) diff --git a/management/daemon.py b/management/daemon.py index 1b374c18..11fe3163 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -179,16 +179,15 @@ def mail_aliases(): if request.args.get("format", "") == "json": return json_response(get_mail_aliases_ex(env)) else: - return "".join(source+"\t"+destination+"\t"+applies_inbound+"\t"+applies_outbound+"\n" for source, destination, applies_inbound, applies_outbound in get_mail_aliases(env)) + return "".join(address+"\t"+receivers+"\t"+senders+"\n" for address, receivers, senders 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', ''), - request.form.get('applies_inbound', '') == '1', - request.form.get('applies_outbound', '') == '1', + request.form.get('address', ''), + request.form.get('receivers', ''), + request.form.get('senders', ''), env, update_if_exists=(request.form.get('update_if_exists', '') == '1') ) diff --git a/management/mailconfig.py b/management/mailconfig.py index c5ff34e3..5315cf1b 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -58,7 +58,7 @@ def sanitize_idn_email_address(email): except (ValueError, idna.IDNAError): # ValueError: String does not have a single @-sign, so it is not # a valid email address. IDNAError: Domain part is not IDNA-valid. - # Validation is not this function's job, so return value unchanged. + # Validation is not this function's job, so return value unchanged. # If there are non-ASCII characters it will be filtered out by # validate_email. return email @@ -181,13 +181,13 @@ def get_admins(env): return users def get_mail_aliases(env): - # Returns a sorted list of tuples of (alias, forward-to string, applies-to-inbound-mail, applies-to-outbound-mail). + # Returns a sorted list of tuples of (address, forward-tos, permitted-senders). c = open_database(env) - c.execute('SELECT source, destination, applies_inbound, applies_outbound FROM aliases') - aliases = { row[0]: row[1:4] for row in c.fetchall() } # make dict + c.execute('SELECT address, receivers, senders FROM aliases') + aliases = { row[0]: row[1:3] 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) ] + aliases = [ (address,) + aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ] return aliases def get_mail_aliases_ex(env): @@ -199,11 +199,10 @@ def get_mail_aliases_ex(env): # domain: "domain.tld", # alias: [ # { - # source: "name@domain.tld", # IDNA-encoded - # source_display: "name@domain.tld", # full Unicode - # destination: ["target1@domain.com", "target2@domain.com", ...], - # applies_inbound: True|False - # applies_outbound: True|False + # address: "name@domain.tld", # IDNA-encoded + # address_display: "name@domain.tld", # full Unicode + # receivers: ["user1@domain.com", "receiver-only1@domain.com", ...], + # senders: ["user1@domain.com", "sender-only1@domain.com", ...], # required: True|False # }, # ... @@ -214,10 +213,10 @@ def get_mail_aliases_ex(env): required_aliases = get_required_aliases(env) domains = {} - for source, destination, applies_inbound, applies_outbound in get_mail_aliases(env): + for address, receivers, senders in get_mail_aliases(env): # get alias info - domain = get_domain(source) - required = (source in required_aliases) + domain = get_domain(address) + required = (address in required_aliases) # add to list if not domain in domains: @@ -226,20 +225,19 @@ def get_mail_aliases_ex(env): "aliases": [], } domains[domain]["aliases"].append({ - "source": source, - "source_display": prettify_idn_email_address(source), - "destination": [prettify_idn_email_address(d.strip()) for d in destination.split(",")], - "applies_inbound": True if applies_inbound == 1 else False, - "applies_outbound": True if applies_outbound == 1 else False, + "address": address, + "address_display": prettify_idn_email_address(address), + "receivers": [prettify_idn_email_address(r.strip()) for r in receivers.split(",")], + "senders": [prettify_idn_email_address(s.strip()) for s in senders.split(",")], "required": required, }) # Sort domains. domains = [domains[domain] for domain in utils.sort_domains(domains.keys(), env)] - # Sort aliases within each domain first by required-ness then lexicographically by source address. + # Sort aliases within each domain first by required-ness then lexicographically by address. for domain in domains: - domain["aliases"].sort(key = lambda alias : (alias["required"], alias["source"])) + domain["aliases"].sort(key = lambda alias : (alias["required"], alias["address"])) return domains def get_domain(emailaddr, as_unicode=True): @@ -253,8 +251,8 @@ def get_mail_domains(env, filter_aliases=lambda alias : True): # Returns the domain names (IDNA-encoded) of all of the email addresses # configured on the system. return set( - [get_domain(addr, as_unicode=False) for addr in get_mail_users(env)] - + [get_domain(source, as_unicode=False) for source, *_ in get_mail_aliases(env) if filter_aliases(source) ] + [get_domain(login, as_unicode=False) for login in get_mail_users(env)] + + [get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ] ) def add_mail_user(email, pw, privs, env): @@ -410,67 +408,82 @@ def add_remove_mail_user_privilege(email, priv, action, env): return "OK" -def add_mail_alias(source, destination, applies_inbound, applies_outbound, env, update_if_exists=False, do_kick=True): +def add_mail_alias(address, receivers, senders, env, update_if_exists=False, do_kick=True): # convert Unicode domain to IDNA - source = sanitize_idn_email_address(source) + address = sanitize_idn_email_address(address) # Our database is case sensitive (oops), which affects mail delivery # (Postfix always queries in lowercase?), so force lowercase. - source = source.lower() + address = address.lower() - # validate source - source = source.strip() - if source == "": - return ("No incoming email address provided.", 400) - if not validate_email(source, mode='alias'): - return ("Invalid incoming email address (%s)." % source, 400) + # validate address + address = address.strip() + if address == "": + return ("No email address provided.", 400) + if not validate_email(address, mode='alias'): + return ("Invalid email address (%s)." % address, 400) + + # validate receivers + validated_receivers = [] + receivers = receivers.strip() # extra checks for email addresses used in domain control validation - is_dcv_source = is_dcv_address(source) - - # validate destination - dests = [] - destination = destination.strip() + is_dcv_source = is_dcv_address(address) # Postfix allows a single @domain.tld as the destination, which means # the local part on the address is preserved in the rewrite. We must # try to convert Unicode to IDNA first before validating that it's a # legitimate alias address. Don't allow this sort of rewriting for # DCV source addresses. - d1 = sanitize_idn_email_address(destination) - if validate_email(d1, mode='alias') and not is_dcv_source: - dests.append(d1) + r1 = sanitize_idn_email_address(receivers) + if validate_email(r1, mode='alias') and not is_dcv_source: + validated_receivers.append(r1) else: # Parse comma and \n-separated destination emails & validate. In this - # case, the recipients must be complete email addresses. - for line in destination.split("\n"): + # case, the receivers must be complete email addresses. + for line in receivers.split("\n"): for email in line.split(","): email = email.strip() if email == "": continue email = sanitize_idn_email_address(email) # Unicode => IDNA if not validate_email(email): - return ("Invalid destination email address (%s)." % email, 400) + return ("Invalid receiver email address (%s)." % email, 400) if is_dcv_source and not is_dcv_address(email) and "admin" not in get_mail_user_privileges(email, env, empty_on_error=True): # Make domain control validation hijacking a little harder to mess up by # requiring aliases for email addresses typically used in DCV to forward # only to accounts that are administrators on this system. return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400) - dests.append(email) - if len(destination) == 0: - return ("No destination email address(es) provided.", 400) - destination = ",".join(dests) + validated_receivers.append(email) + receivers = ",".join(validated_receivers) + + valid_logins = get_mail_users(env) + + # validate senders + validated_senders = [] + senders = senders.strip() + + # Parse comma and \n-separated sender logins & validate. The senders must be + # valid usernames. + for line in senders.split("\n"): + for login in line.split(","): + login = login.strip() + if login == "": continue + if login not in valid_logins: + return ("Invalid sender login (%s)." % login, 400) + validated_senders.append(login) + senders = ",".join(validated_senders) # save to db conn, c = open_database(env, with_connection=True) try: - c.execute("INSERT INTO aliases (source, destination, applies_inbound, applies_outbound) VALUES (?, ?, ?, ?)", (source, destination, 1 if applies_inbound else 0, 1 if applies_outbound else 0)) + c.execute("INSERT INTO aliases (address, receivers, senders) VALUES (?, ?, ?)", (address, receivers, senders)) return_status = "alias added" except sqlite3.IntegrityError: if not update_if_exists: - return ("Alias already exists (%s)." % source, 400) + return ("Alias already exists (%s)." % address, 400) else: - c.execute("UPDATE aliases SET destination = ?, applies_inbound = ?, applies_outbound = ? WHERE source = ?", (destination, 1 if applies_inbound else 0, 1 if applies_outbound else 0, source)) + c.execute("UPDATE aliases SET receivers = ?, senders = ? WHERE address = ?", (receivers, senders, address)) return_status = "alias updated" conn.commit() @@ -479,15 +492,15 @@ def add_mail_alias(source, destination, applies_inbound, applies_outbound, env, # Update things in case any new domains are added. return kick(env, return_status) -def remove_mail_alias(source, env, do_kick=True): +def remove_mail_alias(address, env, do_kick=True): # convert Unicode domain to IDNA - source = sanitize_idn_email_address(source) + address = sanitize_idn_email_address(address) # remove conn, c = open_database(env, with_connection=True) - c.execute("DELETE FROM aliases WHERE source=?", (source,)) + c.execute("DELETE FROM aliases WHERE address=?", (address,)) if c.rowcount != 1: - return ("That's not an alias (%s)." % source, 400) + return ("That's not an alias (%s)." % address, 400) conn.commit() if do_kick: @@ -539,34 +552,34 @@ def kick(env, mail_result=None): existing_aliases = get_mail_aliases(env) required_aliases = get_required_aliases(env) - def ensure_admin_alias_exists(source): + def ensure_admin_alias_exists(address): # If a user account exists with that address, we're good. - if source in existing_users: + if address in existing_users: return # Does this alias exists? - for s, *_ in existing_aliases: - if s == source: + for a, *_ in existing_aliases: + if a == address: return # Doesn't exist. administrator = get_system_administrator(env) - if source == administrator: return # don't make an alias from the administrator to itself --- this alias must be created manually - add_mail_alias(source, administrator, True, True, env, do_kick=False) - results.append("added alias %s (=> %s)\n" % (source, administrator)) + if address == administrator: return # don't make an alias from the administrator to itself --- this alias must be created manually + add_mail_alias(address, administrator, "", env, do_kick=False) + results.append("added alias %s (<==> %s)\n" % (address, administrator)) - for alias in required_aliases: - ensure_admin_alias_exists(alias) + for address in required_aliases: + ensure_admin_alias_exists(address) # 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("@") + for address, receivers, *_ in existing_aliases: + user, domain = address.split("@") 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)) + and address not in required_aliases \ + and receivers == get_system_administrator(env): + remove_mail_alias(address, env, do_kick=False) + results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, receivers)) # Update DNS and nginx in case any domains are added/removed. diff --git a/management/status_checks.py b/management/status_checks.py index 2825654c..52494d49 100755 --- a/management/status_checks.py +++ b/management/status_checks.py @@ -351,14 +351,14 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles): check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output) def check_alias_exists(alias_name, alias, env, output): - mail_aliases = dict([(source, (destination, applies_inbound)) for source, destination, applies_inbound, *_ in get_mail_aliases(env)]) + mail_aliases = dict([(address, receivers) for address, receivers, *_ in get_mail_aliases(env)]) if alias in mail_aliases: - if mail_aliases[alias][1]: - output.print_ok("%s exists as an inbound mail alias. [%s ↦ %s]" % (alias_name, alias, mail_aliases[alias][0])) + if mail_aliases[alias]: + output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_aliases[alias])) else: - output.print_error("%s exists as a mail alias [%s ↦ %s] but is not enabled for inbound email." % (alias_name, alias, mail_aliases[alias][0])) + output.print_error("""You must set the destination of the mail alias for %s to direct email to you or another administrator.""" % alias) else: - output.print_error("""You must add an inbound mail alias for %s which directs email to you or another administrator.""" % alias) + output.print_error("""You must add a mail alias for %s which directs email to you or another administrator.""" % alias) def check_dns_zone(domain, env, output, dns_zonefiles): # If a DS record is set at the registrar, check DNSSEC first because it will affect the NS query. @@ -495,7 +495,7 @@ def check_mail_domain(domain, env, output): # Check that the postmaster@ email address exists. Not required if the domain has a # catch-all address or domain alias. - if "@" + domain not in [source for source, *_ in get_mail_aliases(env)]: + if "@" + domain not in [address for address, *_ in get_mail_aliases(env)]: check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output) # Stop if the domain is listed in the Spamhaus Domain Block List. @@ -647,7 +647,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring return "*." + idna.encode(dns_name[2:]).decode('ascii') else: return idna.encode(dns_name).decode('ascii') - + try: sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName) for san in sans: diff --git a/management/templates/aliases.html b/management/templates/aliases.html index dceaac57..c0d0ff35 100644 --- a/management/templates/aliases.html +++ b/management/templates/aliases.html @@ -7,7 +7,7 @@

Add a mail alias

-

Aliases are email forwarders. An alias can forward email to a mail user or to any email address.

+

An alias can forward email to a mail user or to any email address. You can separately grant permission to one or more users to send as an alias.

@@ -31,20 +31,15 @@
- +
- +
- +
- +
@@ -61,8 +56,8 @@ Alias
- Direction Forwards To + Permitted Senders @@ -83,8 +78,8 @@ - - + +
@@ -111,23 +106,12 @@ function show_aliases() { n.attr('id', ''); if (alias.required) n.addClass('alias-required'); - n.attr('data-email', alias.source_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend - n.find('td.email').text(alias.source_display) - if (!alias.applies_inbound && !alias.applies_outbound) { - n.find('td.direction').text('') - n.attr('data-direction', 'disabled'); - } else if (!alias.applies_inbound && alias.applies_outbound) { - n.find('td.direction').text('↤') - n.attr('data-direction', 'outbound'); - } else if (alias.applies_inbound && !alias.applies_outbound) { - n.find('td.direction').text('↦') - n.attr('data-direction', 'inbound'); - } else if (alias.applies_inbound && alias.applies_outbound) { - n.find('td.direction').text('↮') - n.attr('data-direction', 'bidirectional'); - } - for (var j = 0; j < alias.destination.length; j++) - n.find('td.target').append($("
").text(alias.destination[j])) + n.attr('data-email', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend + n.find('td.email').text(alias.address_display) + for (var j = 0; j < alias.receivers.length; j++) + n.find('td.receivers').append($("
").text(alias.receivers[j])) + for (var j = 0; j < alias.senders.length; j++) + n.find('td.senders').append($("
").text(alias.senders[j])) $('#alias_table tbody').append(n); } } @@ -140,22 +124,22 @@ function show_aliases() { if ($(this).attr('data-mode') == "regular") { $('#addaliasEmail').attr('type', 'email'); $('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)'); - $('#addaliasDirection').val('bidirectional'); - $('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); + $('#addaliasReceivers').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); + $('#addaliasSenders').attr('placeholder', 'allow these users to send as this alias (one per line or separated by commas)'); $('#alias_mode_info').slideUp(); } else if ($(this).attr('data-mode') == "catchall") { $('#addaliasEmail').attr('type', 'text'); $('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)'); - $('#addaliasDirection').val('outbound'); - $('#addaliasTargets').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); + $('#addaliasReceivers').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); + $('#addaliasSenders').attr('placeholder', 'allow these users to send as any address on this domain (one per line or separated by commas)'); $('#alias_mode_info').slideDown(); $('#alias_mode_info span').addClass('hidden'); $('#alias_mode_info span.catchall').removeClass('hidden'); } else if ($(this).attr('data-mode') == "domainalias") { $('#addaliasEmail').attr('type', 'text'); $('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)'); - $('#addaliasDirection').val('inbound'); - $('#addaliasTargets').attr('placeholder', 'forward to domain (@yourdomain.com)'); + $('#addaliasReceivers').attr('placeholder', 'forward to domain (@yourdomain.com)'); + $('#addaliasSenders').attr('placeholder', 'allow these users to send as any address on this domain (one per line or separated by commas)'); $('#alias_mode_info').slideDown(); $('#alias_mode_info span').addClass('hidden'); $('#alias_mode_info span.domainalias').removeClass('hidden'); @@ -168,18 +152,17 @@ function show_aliases() { var is_alias_add_update = false; function do_add_alias() { var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias"; - var email = $("#addaliasEmail").val(); - var direction = $("#addaliasDirection").val(); - var targets = $("#addaliasTargets").val(); + var form_address = $("#addaliasEmail").val(); + var form_receivers = $("#addaliasReceivers").val(); + var form_senders = $("#addaliasSenders").val(); api( "/mail/aliases/add", "POST", { update_if_exists: is_alias_add_update ? '1' : '0', - source: email, - destination: targets, - applies_inbound: (direction == 'bidirectional' || direction == 'inbound') ? '1' : '0', - applies_outbound: (direction == 'bidirectional' || direction == 'outbound') ? '1' : '0' + address: form_address, + receivers: form_receivers, + senders: form_senders }, function(r) { // Responses are multiple lines of pre-formatted text. @@ -196,13 +179,8 @@ function do_add_alias() { function aliases_reset_form() { $("#addaliasEmail").prop('disabled', false); $("#addaliasEmail").val('') - if ($('#alias_type_buttons button').attr('data-mode') == "regular") - $('#addaliasDirection').val('bidirectional'); - else if ($('#alias_type_buttons button').attr('data-mode') == "catchall") - $('#alias_type_buttons').val('outbound'); - else if ($('#addaliasDirection button').attr('data-mode') == "domainalias") - $('#addaliasDirection').val('inbound'); - $("#addaliasTargets").val('') + $("#addaliasReceivers").val('') + $("#addaliasSenders").val('') $('#alias-cancel').addClass('hidden'); $('#add-alias-button').text('Add Alias'); is_alias_add_update = false; @@ -210,12 +188,15 @@ function aliases_reset_form() { function aliases_edit(elem) { var email = $(elem).parents('tr').attr('data-email'); - var targetdivs = $(elem).parents('tr').find('.target div'); - var targets = ""; - for (var i = 0; i < targetdivs.length; i++) - targets += $(targetdivs[i]).text() + "\n"; - var direction = $(elem).parents('tr').attr('data-direction') - if (email.charAt(0) == '@' && targets.charAt(0) == '@') + var receiverdivs = $(elem).parents('tr').find('.receivers div'); + var senderdivs = $(elem).parents('tr').find('.senders div'); + var receivers = ""; + for (var i = 0; i < receiverdivs.length; i++) + receivers += $(receiverdivs[i]).text() + "\n"; + var senders = ""; + for (var i = 0; i < senderdivs.length; i++) + senders += $(senderdivs[i]).text() + "\n"; + if (email.charAt(0) == '@' && receivers.charAt(0) == '@') $('#alias_type_buttons button[data-mode="domainalias"]').click(); else if (email.charAt(0) == '@') $('#alias_type_buttons button[data-mode="catchall"]').click(); @@ -224,15 +205,15 @@ function aliases_edit(elem) { $('#alias-cancel').removeClass('hidden'); $("#addaliasEmail").prop('disabled', true); $("#addaliasEmail").val(email); - $('#addaliasDirection').val(direction); - $("#addaliasTargets").val(targets); + $("#addaliasReceivers").val(receivers); + $("#addaliasSenders").val(senders); $('#add-alias-button').text('Update'); $('body').animate({ scrollTop: 0 }) is_alias_add_update = true; } function aliases_remove(elem) { - var email = $(elem).parents('tr').attr('data-email'); + var row_address = $(elem).parents('tr').attr('data-email'); show_modal_confirm( "Remove Alias", "Remove " + email + "?", @@ -242,7 +223,7 @@ function aliases_remove(elem) { "/mail/aliases/remove", "POST", { - source: email + address: row_address }, function(r) { // Responses are multiple lines of pre-formatted text. diff --git a/setup/mail-users.sh b/setup/mail-users.sh index fadbdd8d..fe12b16a 100755 --- a/setup/mail-users.sh +++ b/setup/mail-users.sh @@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite if [ ! -f $db_path ]; then echo Creating new user database: $db_path; echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path; - echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, applies_inbound INTEGER NOT NULL DEFAULT 1, applies_outbound INTEGER NOT NULL DEFAULT 1);" | sqlite3 $db_path; + echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL UNIQUE, receivers TEXT NOT NULL, senders TEXT NOT NULL);" | sqlite3 $db_path; fi # ### User Authentication @@ -82,7 +82,7 @@ tools/editconf.py /etc/postfix/main.cf \ # Matches from the users table take priority over (direct) aliases. cat > /etc/postfix/sender-login-maps.cf << EOF; dbpath=$db_path -query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND applies_outbound=1 UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; +query = SELECT senders from (SELECT senders, 0 as priority FROM aliases WHERE address='%s' UNION SELECT email as senders, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; EOF # ### Destination Validation @@ -98,7 +98,7 @@ tools/editconf.py /etc/postfix/main.cf \ # SQL statement to check if we handle mail for a domain, either for users or aliases. cat > /etc/postfix/virtual-mailbox-domains.cf << EOF; dbpath=$db_path -query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s' AND applies_inbound=1 +query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE address LIKE '%%@%s' EOF # SQL statement to check if we handle mail for a user. @@ -129,7 +129,7 @@ EOF # postfix's preference for aliases for whole email addresses. cat > /etc/postfix/virtual-alias-maps.cf << EOF; dbpath=$db_path -query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND applies_inbound=1 UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; +query = SELECT receivers from (SELECT receivers, 0 as priority FROM aliases WHERE address='%s' UNION SELECT email as receivers, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; EOF # Restart Services diff --git a/setup/migrate.py b/setup/migrate.py index e181cec4..0269f8ef 100755 --- a/setup/migrate.py +++ b/setup/migrate.py @@ -102,9 +102,56 @@ def migration_8(env): os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')) def migration_9(env): + # Switch from storing alias ownership in one column (used for both + # directions) to two columns (one for determining inbound forward-tos and + # one for determining outbound permitted-senders). This was motivated by the + # addition of #427 ("Reject outgoing mail if FROM does not match Login") - + # which introduced the notion of outbound permitted-senders. db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite') - shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD COLUMN applies_inbound INTEGER NOT NULL DEFAULT 1"]) - shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD COLUMN applies_outbound INTEGER NOT NULL DEFAULT 1"]) + # Move the old aliases table to one side. + shell("check_call", ["sqlite3", db, "ALTER TABLE aliases RENAME TO aliases_8"]) + # Create the new aliases table, initially empty. + shell("check_call", ["sqlite3", db, "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL UNIQUE, receivers TEXT NOT NULL, senders TEXT NOT NULL)"]) + + import sqlite3 + conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/users.sqlite")) + + c = conn.cursor() + c.execute('SELECT email FROM users') + valid_logins = [ row[0] for row in c.fetchall() ] + + c = conn.cursor() + c.execute('SELECT source, destination FROM aliases_8') + aliases = { row[0]: row[1] for row in c.fetchall() } + + # Populate the new aliases table. Forward-to addresses (receivers) is taken + # directly from the old destination column. Permitted-sender logins + # (senders) is made up of only those addresses in the old destination column + # that are valid logins, as other values are not relevant. Their presence + # would not do any harm, except that it would make the aliases UI confusing + # on upgraded boxes. + for source in aliases: + + address = source + receivers = aliases[source] + + validated_senders = [] + for login in aliases[source].split(","): + login = login.strip() + if login == "": continue + if login in valid_logins: + validated_senders.append(login) + + senders = ",".join(validated_senders) + + c = conn.cursor() + c.execute("INSERT INTO aliases (address, receivers, senders) VALUES (?, ?, ?)", (address, receivers, senders)) + + # Save. + conn.commit() + + # Delete the old aliases table. + shell("check_call", ["sqlite3", db, "DROP TABLE aliases_8"]) def get_current_migration(): ver = 0 From 423bb8e31794bd303704207938159f62f37c7ea7 Mon Sep 17 00:00:00 2001 From: David Piggott Date: Sat, 4 Jul 2015 21:40:19 +0100 Subject: [PATCH 3/5] Fix remove-alias button breakage --- management/daemon.py | 2 +- management/templates/aliases.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/management/daemon.py b/management/daemon.py index 11fe3163..828cdbf8 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -195,7 +195,7 @@ def mail_aliases_add(): @app.route('/mail/aliases/remove', methods=['POST']) @authorized_personnel_only def mail_aliases_remove(): - return remove_mail_alias(request.form.get('source', ''), env) + return remove_mail_alias(request.form.get('address', ''), env) @app.route('/mail/domains') @authorized_personnel_only diff --git a/management/templates/aliases.html b/management/templates/aliases.html index c0d0ff35..a3dc4c8d 100644 --- a/management/templates/aliases.html +++ b/management/templates/aliases.html @@ -216,7 +216,7 @@ function aliases_remove(elem) { var row_address = $(elem).parents('tr').attr('data-email'); show_modal_confirm( "Remove Alias", - "Remove " + email + "?", + "Remove " + row_address + "?", "Remove", function() { api( From 123ac4fd33ef95eb5e4a76c992ea9bf8fe1e8020 Mon Sep 17 00:00:00 2001 From: David Piggott Date: Sat, 4 Jul 2015 21:48:35 +0100 Subject: [PATCH 4/5] s/email/address/ in aliases UI variable names This makes the frontend consistent with the backend. --- management/templates/aliases.html | 40 +++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/management/templates/aliases.html b/management/templates/aliases.html index a3dc4c8d..219629d8 100644 --- a/management/templates/aliases.html +++ b/management/templates/aliases.html @@ -24,9 +24,9 @@
- +
- +
You may use international (non-ASCII) characters for the domain part of the email address only.
@@ -77,7 +77,7 @@ - + @@ -106,8 +106,8 @@ function show_aliases() { n.attr('id', ''); if (alias.required) n.addClass('alias-required'); - n.attr('data-email', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend - n.find('td.email').text(alias.address_display) + n.attr('data-address', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend + n.find('td.address').text(alias.address_display) for (var j = 0; j < alias.receivers.length; j++) n.find('td.receivers').append($("
").text(alias.receivers[j])) for (var j = 0; j < alias.senders.length; j++) @@ -122,22 +122,22 @@ function show_aliases() { $('#alias_type_buttons button').removeClass('active'); $(this).addClass('active'); if ($(this).attr('data-mode') == "regular") { - $('#addaliasEmail').attr('type', 'email'); - $('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)'); + $('#addaliasAddress').attr('type', 'email'); + $('#addaliasAddress').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)'); $('#addaliasReceivers').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); $('#addaliasSenders').attr('placeholder', 'allow these users to send as this alias (one per line or separated by commas)'); $('#alias_mode_info').slideUp(); } else if ($(this).attr('data-mode') == "catchall") { - $('#addaliasEmail').attr('type', 'text'); - $('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)'); + $('#addaliasAddress').attr('type', 'text'); + $('#addaliasAddress').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)'); $('#addaliasReceivers').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); $('#addaliasSenders').attr('placeholder', 'allow these users to send as any address on this domain (one per line or separated by commas)'); $('#alias_mode_info').slideDown(); $('#alias_mode_info span').addClass('hidden'); $('#alias_mode_info span.catchall').removeClass('hidden'); } else if ($(this).attr('data-mode') == "domainalias") { - $('#addaliasEmail').attr('type', 'text'); - $('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)'); + $('#addaliasAddress').attr('type', 'text'); + $('#addaliasAddress').attr('placeholder', 'incoming domain (@yourdomain.com)'); $('#addaliasReceivers').attr('placeholder', 'forward to domain (@yourdomain.com)'); $('#addaliasSenders').attr('placeholder', 'allow these users to send as any address on this domain (one per line or separated by commas)'); $('#alias_mode_info').slideDown(); @@ -152,7 +152,7 @@ function show_aliases() { var is_alias_add_update = false; function do_add_alias() { var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias"; - var form_address = $("#addaliasEmail").val(); + var form_address = $("#addaliasAddress").val(); var form_receivers = $("#addaliasReceivers").val(); var form_senders = $("#addaliasSenders").val(); api( @@ -177,8 +177,8 @@ function do_add_alias() { } function aliases_reset_form() { - $("#addaliasEmail").prop('disabled', false); - $("#addaliasEmail").val('') + $("#addaliasAddress").prop('disabled', false); + $("#addaliasAddress").val('') $("#addaliasReceivers").val('') $("#addaliasSenders").val('') $('#alias-cancel').addClass('hidden'); @@ -187,7 +187,7 @@ function aliases_reset_form() { } function aliases_edit(elem) { - var email = $(elem).parents('tr').attr('data-email'); + var address = $(elem).parents('tr').attr('data-address'); var receiverdivs = $(elem).parents('tr').find('.receivers div'); var senderdivs = $(elem).parents('tr').find('.senders div'); var receivers = ""; @@ -196,15 +196,15 @@ function aliases_edit(elem) { var senders = ""; for (var i = 0; i < senderdivs.length; i++) senders += $(senderdivs[i]).text() + "\n"; - if (email.charAt(0) == '@' && receivers.charAt(0) == '@') + if (address.charAt(0) == '@' && receivers.charAt(0) == '@') $('#alias_type_buttons button[data-mode="domainalias"]').click(); - else if (email.charAt(0) == '@') + else if (address.charAt(0) == '@') $('#alias_type_buttons button[data-mode="catchall"]').click(); else $('#alias_type_buttons button[data-mode="regular"]').click(); $('#alias-cancel').removeClass('hidden'); - $("#addaliasEmail").prop('disabled', true); - $("#addaliasEmail").val(email); + $("#addaliasAddress").prop('disabled', true); + $("#addaliasAddress").val(address); $("#addaliasReceivers").val(receivers); $("#addaliasSenders").val(senders); $('#add-alias-button').text('Update'); @@ -213,7 +213,7 @@ function aliases_edit(elem) { } function aliases_remove(elem) { - var row_address = $(elem).parents('tr').attr('data-email'); + var row_address = $(elem).parents('tr').attr('data-address'); show_modal_confirm( "Remove Alias", "Remove " + row_address + "?", From 5924d0fe0dcf374f5db3d27bcd1486f9411fc1d4 Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Fri, 14 Aug 2015 23:05:08 +0000 Subject: [PATCH 5/5] various cleanup related to the new permitted_senders column for aliases --- management/daemon.py | 6 +- management/mailconfig.py | 75 +++++++++++++----------- management/templates/aliases.html | 97 +++++++++++++++++++------------ setup/mail-users.sh | 33 +++++++---- setup/migrate.py | 56 +++--------------- tools/mail.py | 4 +- 6 files changed, 136 insertions(+), 135 deletions(-) diff --git a/management/daemon.py b/management/daemon.py index 828cdbf8..36470991 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -179,15 +179,15 @@ def mail_aliases(): if request.args.get("format", "") == "json": return json_response(get_mail_aliases_ex(env)) else: - return "".join(address+"\t"+receivers+"\t"+senders+"\n" for address, receivers, senders in get_mail_aliases(env)) + return "".join(address+"\t"+receivers+"\t"+(senders or "")+"\n" for address, receivers, senders 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('address', ''), - request.form.get('receivers', ''), - request.form.get('senders', ''), + request.form.get('forwards_to', ''), + request.form.get('permitted_senders', ''), env, update_if_exists=(request.form.get('update_if_exists', '') == '1') ) diff --git a/management/mailconfig.py b/management/mailconfig.py index 5315cf1b..d7cb6d3b 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -183,11 +183,11 @@ def get_admins(env): def get_mail_aliases(env): # Returns a sorted list of tuples of (address, forward-tos, permitted-senders). c = open_database(env) - c.execute('SELECT address, receivers, senders FROM aliases') - aliases = { row[0]: row[1:3] for row in c.fetchall() } # make dict + c.execute('SELECT source, destination, permitted_senders FROM aliases') + aliases = { row[0]: row for row in c.fetchall() } # make dict # put in a canonical order: sort by domain, then by email address lexicographically - aliases = [ (address,) + aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ] + aliases = [ aliases[address] for address in utils.sort_email_addresses(aliases.keys(), env) ] return aliases def get_mail_aliases_ex(env): @@ -201,8 +201,8 @@ def get_mail_aliases_ex(env): # { # address: "name@domain.tld", # IDNA-encoded # address_display: "name@domain.tld", # full Unicode - # receivers: ["user1@domain.com", "receiver-only1@domain.com", ...], - # senders: ["user1@domain.com", "sender-only1@domain.com", ...], + # forwards_to: ["user1@domain.com", "receiver-only1@domain.com", ...], + # permitted_senders: ["user1@domain.com", "sender-only1@domain.com", ...] OR null, # required: True|False # }, # ... @@ -213,7 +213,7 @@ def get_mail_aliases_ex(env): required_aliases = get_required_aliases(env) domains = {} - for address, receivers, senders in get_mail_aliases(env): + for address, forwards_to, permitted_senders in get_mail_aliases(env): # get alias info domain = get_domain(address) required = (address in required_aliases) @@ -227,8 +227,8 @@ def get_mail_aliases_ex(env): domains[domain]["aliases"].append({ "address": address, "address_display": prettify_idn_email_address(address), - "receivers": [prettify_idn_email_address(r.strip()) for r in receivers.split(",")], - "senders": [prettify_idn_email_address(s.strip()) for s in senders.split(",")], + "forwards_to": [prettify_idn_email_address(r.strip()) for r in forwards_to.split(",")], + "permitted_senders": [prettify_idn_email_address(s.strip()) for s in permitted_senders.split(",")] if permitted_senders is not None else None, "required": required, }) @@ -408,7 +408,7 @@ def add_remove_mail_user_privilege(email, priv, action, env): return "OK" -def add_mail_alias(address, receivers, senders, env, update_if_exists=False, do_kick=True): +def add_mail_alias(address, forwards_to, permitted_senders, env, update_if_exists=False, do_kick=True): # convert Unicode domain to IDNA address = sanitize_idn_email_address(address) @@ -423,9 +423,9 @@ def add_mail_alias(address, receivers, senders, env, update_if_exists=False, do_ if not validate_email(address, mode='alias'): return ("Invalid email address (%s)." % address, 400) - # validate receivers - validated_receivers = [] - receivers = receivers.strip() + # validate forwards_to + validated_forwards_to = [] + forwards_to = forwards_to.strip() # extra checks for email addresses used in domain control validation is_dcv_source = is_dcv_address(address) @@ -435,14 +435,14 @@ def add_mail_alias(address, receivers, senders, env, update_if_exists=False, do_ # try to convert Unicode to IDNA first before validating that it's a # legitimate alias address. Don't allow this sort of rewriting for # DCV source addresses. - r1 = sanitize_idn_email_address(receivers) + r1 = sanitize_idn_email_address(forwards_to) if validate_email(r1, mode='alias') and not is_dcv_source: - validated_receivers.append(r1) + validated_forwards_to.append(r1) else: # Parse comma and \n-separated destination emails & validate. In this - # case, the receivers must be complete email addresses. - for line in receivers.split("\n"): + # case, the forwards_to must be complete email addresses. + for line in forwards_to.split("\n"): for email in line.split(","): email = email.strip() if email == "": continue @@ -454,36 +454,45 @@ def add_mail_alias(address, receivers, senders, env, update_if_exists=False, do_ # requiring aliases for email addresses typically used in DCV to forward # only to accounts that are administrators on this system. return ("This alias can only have administrators of this system as destinations because the address is frequently used for domain control validation.", 400) - validated_receivers.append(email) - receivers = ",".join(validated_receivers) + validated_forwards_to.append(email) + # validate permitted_senders valid_logins = get_mail_users(env) + validated_permitted_senders = [] + permitted_senders = permitted_senders.strip() - # validate senders - validated_senders = [] - senders = senders.strip() - - # Parse comma and \n-separated sender logins & validate. The senders must be + # Parse comma and \n-separated sender logins & validate. The permitted_senders must be # valid usernames. - for line in senders.split("\n"): + for line in permitted_senders.split("\n"): for login in line.split(","): login = login.strip() if login == "": continue if login not in valid_logins: - return ("Invalid sender login (%s)." % login, 400) - validated_senders.append(login) - senders = ",".join(validated_senders) + return ("Invalid permitted sender: %s is not a user on this system." % login, 400) + validated_permitted_senders.append(login) + + # Make sure the alias has either a forwards_to or a permitted_sender. + if len(validated_forwards_to) + len(validated_permitted_senders) == 0: + return ("The alias must either forward to an address or have a permitted sender.", 400) # save to db + + forwards_to = ",".join(validated_forwards_to) + + if len(validated_permitted_senders) == 0: + permitted_senders = None + else: + permitted_senders = ",".join(validated_permitted_senders) + conn, c = open_database(env, with_connection=True) try: - c.execute("INSERT INTO aliases (address, receivers, senders) VALUES (?, ?, ?)", (address, receivers, senders)) + c.execute("INSERT INTO aliases (source, destination, permitted_senders) VALUES (?, ?, ?)", (address, forwards_to, permitted_senders)) return_status = "alias added" except sqlite3.IntegrityError: if not update_if_exists: return ("Alias already exists (%s)." % address, 400) else: - c.execute("UPDATE aliases SET receivers = ?, senders = ? WHERE address = ?", (receivers, senders, address)) + c.execute("UPDATE aliases SET destination = ?, permitted_senders = ? WHERE source = ?", (forwards_to, permitted_senders, address)) return_status = "alias updated" conn.commit() @@ -498,7 +507,7 @@ def remove_mail_alias(address, env, do_kick=True): # remove conn, c = open_database(env, with_connection=True) - c.execute("DELETE FROM aliases WHERE address=?", (address,)) + c.execute("DELETE FROM aliases WHERE source=?", (address,)) if c.rowcount != 1: return ("That's not an alias (%s)." % address, 400) conn.commit() @@ -573,13 +582,13 @@ def kick(env, mail_result=None): # Remove auto-generated postmaster/admin on domains we no # longer have any other email addresses for. - for address, receivers, *_ in existing_aliases: + for address, forwards_to, *_ in existing_aliases: user, domain = address.split("@") if user in ("postmaster", "admin") \ and address not in required_aliases \ - and receivers == get_system_administrator(env): + and forwards_to == get_system_administrator(env): remove_mail_alias(address, env, do_kick=False) - results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, receivers)) + results.append("removed alias %s (was to %s; domain no longer used for email)\n" % (address, forwards_to)) # Update DNS and nginx in case any domains are added/removed. diff --git a/management/templates/aliases.html b/management/templates/aliases.html index 219629d8..e2c03248 100644 --- a/management/templates/aliases.html +++ b/management/templates/aliases.html @@ -7,9 +7,11 @@

Add a mail alias

-

An alias can forward email to a mail user or to any email address. You can separately grant permission to one or more users to send as an alias.

+

Aliases are email forwarders. An alias can forward email to a mail user or to any email address.

- +

To use an alias or any address besides your own login username in outbound mail, the sending user must be included as a permitted sender for the alias.

+ +
@@ -18,8 +20,8 @@
@@ -27,19 +29,39 @@
-
You may use international (non-ASCII) characters for the domain part of the email address only.
+
+ Enter just the part of an email address starting with the @-sign. + You may use international (non-ASCII) characters for the domain part of the email address only. +
- +
- + +
+ Enter just the part of an email address starting with the @-sign. +
- +
+ +
+
+ +
+
@@ -78,7 +100,7 @@ - + @@ -108,10 +130,10 @@ function show_aliases() { if (alias.required) n.addClass('alias-required'); n.attr('data-address', alias.address_display); // this is decoded from IDNA, but will get re-coded to IDNA on the backend n.find('td.address').text(alias.address_display) - for (var j = 0; j < alias.receivers.length; j++) - n.find('td.receivers').append($("
").text(alias.receivers[j])) - for (var j = 0; j < alias.senders.length; j++) - n.find('td.senders').append($("
").text(alias.senders[j])) + for (var j = 0; j < alias.forwards_to.length; j++) + n.find('td.forwardsTo').append($("
").text(alias.forwards_to[j])) + for (var j = 0; j < (alias.permitted_senders ? alias.permitted_senders.length : 0); j++) + n.find('td.senders').append($("
").text(alias.permitted_senders[j])) $('#alias_table tbody').append(n); } } @@ -121,28 +143,25 @@ function show_aliases() { $('#alias_type_buttons button').off('click').click(function() { $('#alias_type_buttons button').removeClass('active'); $(this).addClass('active'); + $('#addalias-form .regularalias, #addalias-form .catchall, #addalias-form .domainalias').addClass('hidden'); if ($(this).attr('data-mode') == "regular") { $('#addaliasAddress').attr('type', 'email'); - $('#addaliasAddress').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)'); - $('#addaliasReceivers').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); - $('#addaliasSenders').attr('placeholder', 'allow these users to send as this alias (one per line or separated by commas)'); + $('#addaliasAddress').attr('placeholder', 'you@yourdomain.com (incoming email address)'); + $('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas'); $('#alias_mode_info').slideUp(); + $('#addalias-form .regularalias').removeClass('hidden'); } else if ($(this).attr('data-mode') == "catchall") { $('#addaliasAddress').attr('type', 'text'); - $('#addaliasAddress').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)'); - $('#addaliasReceivers').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)'); - $('#addaliasSenders').attr('placeholder', 'allow these users to send as any address on this domain (one per line or separated by commas)'); + $('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)'); + $('#addaliasForwardsTo').attr('placeholder', 'one address per line or separated by commas'); $('#alias_mode_info').slideDown(); - $('#alias_mode_info span').addClass('hidden'); - $('#alias_mode_info span.catchall').removeClass('hidden'); + $('#addalias-form .catchall').removeClass('hidden'); } else if ($(this).attr('data-mode') == "domainalias") { $('#addaliasAddress').attr('type', 'text'); - $('#addaliasAddress').attr('placeholder', 'incoming domain (@yourdomain.com)'); - $('#addaliasReceivers').attr('placeholder', 'forward to domain (@yourdomain.com)'); - $('#addaliasSenders').attr('placeholder', 'allow these users to send as any address on this domain (one per line or separated by commas)'); + $('#addaliasAddress').attr('placeholder', '@yourdomain.com (incoming catch-all domain)'); + $('#addaliasForwardsTo').attr('placeholder', '@otherdomain.com (forward to other domain)'); $('#alias_mode_info').slideDown(); - $('#alias_mode_info span').addClass('hidden'); - $('#alias_mode_info span.domainalias').removeClass('hidden'); + $('#addalias-form .domainalias').removeClass('hidden'); } }) $('#alias_type_buttons button[data-mode="regular"]').click(); // init @@ -153,16 +172,20 @@ var is_alias_add_update = false; function do_add_alias() { var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias"; var form_address = $("#addaliasAddress").val(); - var form_receivers = $("#addaliasReceivers").val(); - var form_senders = $("#addaliasSenders").val(); + var form_forwardsto = $("#addaliasForwardsTo").val(); + var form_senders = ($('#addaliasForwardsToAdvanced').prop('checked') ? $("#addaliasSenders").val() : ''); + if ($('#addaliasForwardsToAdvanced').prop('checked') && !/\S/.exec($("#addaliasSenders").val())) { + show_modal_error(title, "You did not enter any permitted senders."); + return false; + } api( "/mail/aliases/add", "POST", { update_if_exists: is_alias_add_update ? '1' : '0', address: form_address, - receivers: form_receivers, - senders: form_senders + forwards_to: form_forwardsto, + permitted_senders: form_senders }, function(r) { // Responses are multiple lines of pre-formatted text. @@ -179,7 +202,7 @@ function do_add_alias() { function aliases_reset_form() { $("#addaliasAddress").prop('disabled', false); $("#addaliasAddress").val('') - $("#addaliasReceivers").val('') + $("#addaliasForwardsTo").val('') $("#addaliasSenders").val('') $('#alias-cancel').addClass('hidden'); $('#add-alias-button').text('Add Alias'); @@ -188,15 +211,15 @@ function aliases_reset_form() { function aliases_edit(elem) { var address = $(elem).parents('tr').attr('data-address'); - var receiverdivs = $(elem).parents('tr').find('.receivers div'); + var receiverdivs = $(elem).parents('tr').find('.forwardsTo div'); var senderdivs = $(elem).parents('tr').find('.senders div'); - var receivers = ""; + var forwardsTo = ""; for (var i = 0; i < receiverdivs.length; i++) - receivers += $(receiverdivs[i]).text() + "\n"; + forwardsTo += $(receiverdivs[i]).text() + "\n"; var senders = ""; for (var i = 0; i < senderdivs.length; i++) senders += $(senderdivs[i]).text() + "\n"; - if (address.charAt(0) == '@' && receivers.charAt(0) == '@') + if (address.charAt(0) == '@' && forwardsTo.charAt(0) == '@') $('#alias_type_buttons button[data-mode="domainalias"]').click(); else if (address.charAt(0) == '@') $('#alias_type_buttons button[data-mode="catchall"]').click(); @@ -205,7 +228,9 @@ function aliases_edit(elem) { $('#alias-cancel').removeClass('hidden'); $("#addaliasAddress").prop('disabled', true); $("#addaliasAddress").val(address); - $("#addaliasReceivers").val(receivers); + $("#addaliasForwardsTo").val(forwardsTo); + $('#addaliasForwardsToAdvanced').prop('checked', senders != ""); + $('#addaliasForwardsToNotAdvanced').prop('checked', senders == ""); $("#addaliasSenders").val(senders); $('#add-alias-button').text('Update'); $('body').animate({ scrollTop: 0 }) diff --git a/setup/mail-users.sh b/setup/mail-users.sh index fe12b16a..e312983e 100755 --- a/setup/mail-users.sh +++ b/setup/mail-users.sh @@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite if [ ! -f $db_path ]; then echo Creating new user database: $db_path; echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path; - echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL UNIQUE, receivers TEXT NOT NULL, senders TEXT NOT NULL);" | sqlite3 $db_path; + echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path; fi # ### User Authentication @@ -71,18 +71,23 @@ tools/editconf.py /etc/postfix/main.cf \ # ### Sender Validation -# Use a Sqlite3 database to set login maps. This is used with -# reject_authenticated_sender_login_mismatch to see if the user is -# allowed to send mail as the FROM address specified in the request. +# We use Postfix's reject_authenticated_sender_login_mismatch filter to +# prevent intra-domain spoofing by logged in but untrusted users in outbound +# email. In all outbound mail (the sender has authenticated), the MAIL FROM +# address (aka envelope or return path address) must be "owned" by the user +# who authenticated. An SQL query will find who are the owners of any given +# address. tools/editconf.py /etc/postfix/main.cf \ smtpd_sender_login_maps=sqlite:/etc/postfix/sender-login-maps.cf -# SQL statement that returns a list of addresses/domains the logged in username -# is allowed to send as. This is similar to virtual-alias-maps.cf (see below). -# Matches from the users table take priority over (direct) aliases. +# Postfix will query the exact address first, where the priority will be alias +# records first, then user records. If there are no matches for the exact +# address, then Postfix will query just the domain part, which we call +# catch-alls and domain aliases. A NULL permitted_senders column means to +# take the value from the destination column. cat > /etc/postfix/sender-login-maps.cf << EOF; dbpath=$db_path -query = SELECT senders from (SELECT senders, 0 as priority FROM aliases WHERE address='%s' UNION SELECT email as senders, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; +query = SELECT permitted_senders FROM (SELECT permitted_senders, 0 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NOT NULL UNION SELECT destination AS permitted_senders, 1 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NULL UNION SELECT email as permitted_senders, 2 AS priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; EOF # ### Destination Validation @@ -95,13 +100,13 @@ tools/editconf.py /etc/postfix/main.cf \ virtual_alias_maps=sqlite:/etc/postfix/virtual-alias-maps.cf \ local_recipient_maps=\$virtual_mailbox_maps -# SQL statement to check if we handle mail for a domain, either for users or aliases. +# SQL statement to check if we handle incoming mail for a domain, either for users or aliases. cat > /etc/postfix/virtual-mailbox-domains.cf << EOF; dbpath=$db_path -query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE address LIKE '%%@%s' +query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s' EOF -# SQL statement to check if we handle mail for a user. +# SQL statement to check if we handle incoming mail for a user. cat > /etc/postfix/virtual-mailbox-maps.cf << EOF; dbpath=$db_path query = SELECT 1 FROM users WHERE email='%s' @@ -127,9 +132,13 @@ EOF # might be returned by the UNION, so the whole query is wrapped in # another select that prioritizes the alias definition to preserve # postfix's preference for aliases for whole email addresses. +# +# Since we might have alias records with an empty destination because +# it might have just permitted_senders, skip any records with an +# empty destination here so that other lower priority rules might match. cat > /etc/postfix/virtual-alias-maps.cf << EOF; dbpath=$db_path -query = SELECT receivers from (SELECT receivers, 0 as priority FROM aliases WHERE address='%s' UNION SELECT email as receivers, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; +query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND destination<>'' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1; EOF # Restart Services diff --git a/setup/migrate.py b/setup/migrate.py index 0269f8ef..6acd0edc 100755 --- a/setup/migrate.py +++ b/setup/migrate.py @@ -102,56 +102,14 @@ def migration_8(env): os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')) def migration_9(env): - # Switch from storing alias ownership in one column (used for both - # directions) to two columns (one for determining inbound forward-tos and - # one for determining outbound permitted-senders). This was motivated by the - # addition of #427 ("Reject outgoing mail if FROM does not match Login") - - # which introduced the notion of outbound permitted-senders. + # Add a column to the aliases table to store permitted_senders, + # which is a list of user account email addresses that are + # permitted to send mail using this alias instead of their own + # address. This was motivated by the addition of #427 ("Reject + # outgoing mail if FROM does not match Login") - which introduced + # the notion of outbound permitted-senders. db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite') - # Move the old aliases table to one side. - shell("check_call", ["sqlite3", db, "ALTER TABLE aliases RENAME TO aliases_8"]) - # Create the new aliases table, initially empty. - shell("check_call", ["sqlite3", db, "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL UNIQUE, receivers TEXT NOT NULL, senders TEXT NOT NULL)"]) - - import sqlite3 - conn = sqlite3.connect(os.path.join(env["STORAGE_ROOT"], "mail/users.sqlite")) - - c = conn.cursor() - c.execute('SELECT email FROM users') - valid_logins = [ row[0] for row in c.fetchall() ] - - c = conn.cursor() - c.execute('SELECT source, destination FROM aliases_8') - aliases = { row[0]: row[1] for row in c.fetchall() } - - # Populate the new aliases table. Forward-to addresses (receivers) is taken - # directly from the old destination column. Permitted-sender logins - # (senders) is made up of only those addresses in the old destination column - # that are valid logins, as other values are not relevant. Their presence - # would not do any harm, except that it would make the aliases UI confusing - # on upgraded boxes. - for source in aliases: - - address = source - receivers = aliases[source] - - validated_senders = [] - for login in aliases[source].split(","): - login = login.strip() - if login == "": continue - if login in valid_logins: - validated_senders.append(login) - - senders = ",".join(validated_senders) - - c = conn.cursor() - c.execute("INSERT INTO aliases (address, receivers, senders) VALUES (?, ?, ?)", (address, receivers, senders)) - - # Save. - conn.commit() - - # Delete the old aliases table. - shell("check_call", ["sqlite3", db, "DROP TABLE aliases_8"]) + shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD permitted_senders TEXT"]) def get_current_migration(): ver = 0 diff --git a/tools/mail.py b/tools/mail.py index e02177b1..d0e546ab 100755 --- a/tools/mail.py +++ b/tools/mail.py @@ -120,10 +120,10 @@ elif sys.argv[1] == "alias" and len(sys.argv) == 2: print(mgmt("/mail/aliases")) elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5: - print(mgmt("/mail/aliases/add", { "source": sys.argv[3], "destination": sys.argv[4] })) + print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] })) elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4: - print(mgmt("/mail/aliases/remove", { "source": sys.argv[3] })) + print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] })) else: print("Invalid command-line arguments.")