Commit e6ff2809 authored by David Piggott's avatar David Piggott

Store and set alias receivers and senders separately for maximum control

parent 3fdfad27
...@@ -179,16 +179,15 @@ def mail_aliases(): ...@@ -179,16 +179,15 @@ def mail_aliases():
if request.args.get("format", "") == "json": if request.args.get("format", "") == "json":
return json_response(get_mail_aliases_ex(env)) return json_response(get_mail_aliases_ex(env))
else: 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']) @app.route('/mail/aliases/add', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def mail_aliases_add(): def mail_aliases_add():
return add_mail_alias( return add_mail_alias(
request.form.get('source', ''), request.form.get('address', ''),
request.form.get('destination', ''), request.form.get('receivers', ''),
request.form.get('applies_inbound', '') == '1', request.form.get('senders', ''),
request.form.get('applies_outbound', '') == '1',
env, env,
update_if_exists=(request.form.get('update_if_exists', '') == '1') update_if_exists=(request.form.get('update_if_exists', '') == '1')
) )
......
This diff is collapsed.
...@@ -351,14 +351,14 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles): ...@@ -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) check_alias_exists("Hostmaster contact address", "hostmaster@" + domain, env, output)
def check_alias_exists(alias_name, alias, 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 alias in mail_aliases:
if mail_aliases[alias][1]: if mail_aliases[alias]:
output.print_ok("%s exists as an inbound mail alias. [%s ↦ %s]" % (alias_name, alias, mail_aliases[alias][0])) output.print_ok("%s exists as a mail alias. [%s ↦ %s]" % (alias_name, alias, mail_aliases[alias]))
else: 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: 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): 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. # 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): ...@@ -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 # Check that the postmaster@ email address exists. Not required if the domain has a
# catch-all address or domain alias. # 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) check_alias_exists("Postmaster contact address", "postmaster@" + domain, env, output)
# Stop if the domain is listed in the Spamhaus Domain Block List. # 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 ...@@ -647,7 +647,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
return "*." + idna.encode(dns_name[2:]).decode('ascii') return "*." + idna.encode(dns_name[2:]).decode('ascii')
else: else:
return idna.encode(dns_name).decode('ascii') return idna.encode(dns_name).decode('ascii')
try: try:
sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName) sans = cert.extensions.get_extension_for_oid(OID_SUBJECT_ALTERNATIVE_NAME).value.get_values_for_type(DNSName)
for san in sans: for san in sans:
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
<h3>Add a mail alias</h3> <h3>Add a mail alias</h3>
<p>Aliases are email forwarders. An alias can forward email to a <a href="javascript:show_panel('users')">mail user</a> or to any email address.</p> <p>An alias can forward email to a <a href="javascript:show_panel('users')">mail user</a> or to any email address. You can separately grant permission to one or more users to send as an alias.</p>
<form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;"> <form class="form-horizontal" role="form" onsubmit="do_add_alias(); return false;">
<div class="form-group"> <div class="form-group">
...@@ -31,20 +31,15 @@ ...@@ -31,20 +31,15 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="addaliasDirection" class="col-sm-1 control-label">Direction</label> <label for="addaliasReceivers" class="col-sm-1 control-label">Forwards To</label>
<div class="col-sm-10"> <div class="col-sm-10">
<select class="form-control" id="addaliasDirection"> <textarea class="form-control" rows="3" id="addaliasReceivers"></textarea>
<option value="disabled">Disabled</option>
<option value="outbound">Outbound only</option>
<option value="inbound">Inbound only</option>
<option value="bidirectional">Both</option>
</select>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="addaliasTargets" class="col-sm-1 control-label">Forward To</label> <label for="addaliasSenders" class="col-sm-1 control-label">Permitted Senders</label>
<div class="col-sm-10"> <div class="col-sm-10">
<textarea class="form-control" rows="3" id="addaliasTargets"></textarea> <textarea class="form-control" rows="3" id="addaliasSenders"></textarea>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
...@@ -61,8 +56,8 @@ ...@@ -61,8 +56,8 @@
<tr> <tr>
<th></th> <th></th>
<th>Alias<br></th> <th>Alias<br></th>
<th>Direction</th>
<th>Forwards To</th> <th>Forwards To</th>
<th>Permitted Senders</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
...@@ -83,8 +78,8 @@ ...@@ -83,8 +78,8 @@
</a> </a>
</td> </td>
<td class='email'> </td> <td class='email'> </td>
<td class='direction'> </td> <td class='receivers'> </td>
<td class='target'> </td> <td class='senders'> </td>
</tr> </tr>
</table> </table>
</div> </div>
...@@ -111,23 +106,12 @@ function show_aliases() { ...@@ -111,23 +106,12 @@ function show_aliases() {
n.attr('id', ''); n.attr('id', '');
if (alias.required) n.addClass('alias-required'); 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.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.source_display) n.find('td.email').text(alias.address_display)
if (!alias.applies_inbound && !alias.applies_outbound) { for (var j = 0; j < alias.receivers.length; j++)
n.find('td.direction').text('') n.find('td.receivers').append($("<div></div>").text(alias.receivers[j]))
n.attr('data-direction', 'disabled'); for (var j = 0; j < alias.senders.length; j++)
} else if (!alias.applies_inbound && alias.applies_outbound) { n.find('td.senders').append($("<div></div>").text(alias.senders[j]))
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($("<div></div>").text(alias.destination[j]))
$('#alias_table tbody').append(n); $('#alias_table tbody').append(n);
} }
} }
...@@ -140,22 +124,22 @@ function show_aliases() { ...@@ -140,22 +124,22 @@ function show_aliases() {
if ($(this).attr('data-mode') == "regular") { if ($(this).attr('data-mode') == "regular") {
$('#addaliasEmail').attr('type', 'email'); $('#addaliasEmail').attr('type', 'email');
$('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)'); $('#addaliasEmail').attr('placeholder', 'incoming email address (e.g. you@yourdomain.com)');
$('#addaliasDirection').val('bidirectional'); $('#addaliasReceivers').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
$('#addaliasTargets').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(); $('#alias_mode_info').slideUp();
} else if ($(this).attr('data-mode') == "catchall") { } else if ($(this).attr('data-mode') == "catchall") {
$('#addaliasEmail').attr('type', 'text'); $('#addaliasEmail').attr('type', 'text');
$('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)'); $('#addaliasEmail').attr('placeholder', 'incoming catch-all address (e.g. @yourdomain.com)');
$('#addaliasDirection').val('outbound'); $('#addaliasReceivers').attr('placeholder', 'forward to these email addresses (one per line or separated by commas)');
$('#addaliasTargets').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').slideDown();
$('#alias_mode_info span').addClass('hidden'); $('#alias_mode_info span').addClass('hidden');
$('#alias_mode_info span.catchall').removeClass('hidden'); $('#alias_mode_info span.catchall').removeClass('hidden');
} else if ($(this).attr('data-mode') == "domainalias") { } else if ($(this).attr('data-mode') == "domainalias") {
$('#addaliasEmail').attr('type', 'text'); $('#addaliasEmail').attr('type', 'text');
$('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)'); $('#addaliasEmail').attr('placeholder', 'incoming domain (@yourdomain.com)');
$('#addaliasDirection').val('inbound'); $('#addaliasReceivers').attr('placeholder', 'forward to domain (@yourdomain.com)');
$('#addaliasTargets').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').slideDown();
$('#alias_mode_info span').addClass('hidden'); $('#alias_mode_info span').addClass('hidden');
$('#alias_mode_info span.domainalias').removeClass('hidden'); $('#alias_mode_info span.domainalias').removeClass('hidden');
...@@ -168,18 +152,17 @@ function show_aliases() { ...@@ -168,18 +152,17 @@ function show_aliases() {
var is_alias_add_update = false; var is_alias_add_update = false;
function do_add_alias() { function do_add_alias() {
var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias"; var title = (!is_alias_add_update) ? "Add Alias" : "Update Alias";
var email = $("#addaliasEmail").val(); var form_address = $("#addaliasEmail").val();
var direction = $("#addaliasDirection").val(); var form_receivers = $("#addaliasReceivers").val();
var targets = $("#addaliasTargets").val(); var form_senders = $("#addaliasSenders").val();
api( api(
"/mail/aliases/add", "/mail/aliases/add",
"POST", "POST",
{ {
update_if_exists: is_alias_add_update ? '1' : '0', update_if_exists: is_alias_add_update ? '1' : '0',
source: email, address: form_address,
destination: targets, receivers: form_receivers,
applies_inbound: (direction == 'bidirectional' || direction == 'inbound') ? '1' : '0', senders: form_senders
applies_outbound: (direction == 'bidirectional' || direction == 'outbound') ? '1' : '0'
}, },
function(r) { function(r) {
// Responses are multiple lines of pre-formatted text. // Responses are multiple lines of pre-formatted text.
...@@ -196,13 +179,8 @@ function do_add_alias() { ...@@ -196,13 +179,8 @@ function do_add_alias() {
function aliases_reset_form() { function aliases_reset_form() {
$("#addaliasEmail").prop('disabled', false); $("#addaliasEmail").prop('disabled', false);
$("#addaliasEmail").val('') $("#addaliasEmail").val('')
if ($('#alias_type_buttons button').attr('data-mode') == "regular") $("#addaliasReceivers").val('')
$('#addaliasDirection').val('bidirectional'); $("#addaliasSenders").val('')
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'); $('#alias-cancel').addClass('hidden');
$('#add-alias-button').text('Add Alias'); $('#add-alias-button').text('Add Alias');
is_alias_add_update = false; is_alias_add_update = false;
...@@ -210,12 +188,15 @@ function aliases_reset_form() { ...@@ -210,12 +188,15 @@ function aliases_reset_form() {
function aliases_edit(elem) { function aliases_edit(elem) {
var email = $(elem).parents('tr').attr('data-email'); var email = $(elem).parents('tr').attr('data-email');
var targetdivs = $(elem).parents('tr').find('.target div'); var receiverdivs = $(elem).parents('tr').find('.receivers div');
var targets = ""; var senderdivs = $(elem).parents('tr').find('.senders div');
for (var i = 0; i < targetdivs.length; i++) var receivers = "";
targets += $(targetdivs[i]).text() + "\n"; for (var i = 0; i < receiverdivs.length; i++)
var direction = $(elem).parents('tr').attr('data-direction') receivers += $(receiverdivs[i]).text() + "\n";
if (email.charAt(0) == '@' && targets.charAt(0) == '@') 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(); $('#alias_type_buttons button[data-mode="domainalias"]').click();
else if (email.charAt(0) == '@') else if (email.charAt(0) == '@')
$('#alias_type_buttons button[data-mode="catchall"]').click(); $('#alias_type_buttons button[data-mode="catchall"]').click();
...@@ -224,15 +205,15 @@ function aliases_edit(elem) { ...@@ -224,15 +205,15 @@ function aliases_edit(elem) {
$('#alias-cancel').removeClass('hidden'); $('#alias-cancel').removeClass('hidden');
$("#addaliasEmail").prop('disabled', true); $("#addaliasEmail").prop('disabled', true);
$("#addaliasEmail").val(email); $("#addaliasEmail").val(email);
$('#addaliasDirection').val(direction); $("#addaliasReceivers").val(receivers);
$("#addaliasTargets").val(targets); $("#addaliasSenders").val(senders);
$('#add-alias-button').text('Update'); $('#add-alias-button').text('Update');
$('body').animate({ scrollTop: 0 }) $('body').animate({ scrollTop: 0 })
is_alias_add_update = true; is_alias_add_update = true;
} }
function aliases_remove(elem) { function aliases_remove(elem) {
var email = $(elem).parents('tr').attr('data-email'); var row_address = $(elem).parents('tr').attr('data-email');
show_modal_confirm( show_modal_confirm(
"Remove Alias", "Remove Alias",
"Remove " + email + "?", "Remove " + email + "?",
...@@ -242,7 +223,7 @@ function aliases_remove(elem) { ...@@ -242,7 +223,7 @@ function aliases_remove(elem) {
"/mail/aliases/remove", "/mail/aliases/remove",
"POST", "POST",
{ {
source: email address: row_address
}, },
function(r) { function(r) {
// Responses are multiple lines of pre-formatted text. // Responses are multiple lines of pre-formatted text.
......
...@@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite ...@@ -21,7 +21,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
if [ ! -f $db_path ]; then if [ ! -f $db_path ]; then
echo Creating new user database: $db_path; 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 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 fi
# ### User Authentication # ### User Authentication
...@@ -82,7 +82,7 @@ tools/editconf.py /etc/postfix/main.cf \ ...@@ -82,7 +82,7 @@ tools/editconf.py /etc/postfix/main.cf \
# Matches from the users table take priority over (direct) aliases. # Matches from the users table take priority over (direct) aliases.
cat > /etc/postfix/sender-login-maps.cf << EOF; cat > /etc/postfix/sender-login-maps.cf << EOF;
dbpath=$db_path 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 EOF
# ### Destination Validation # ### Destination Validation
...@@ -98,7 +98,7 @@ tools/editconf.py /etc/postfix/main.cf \ ...@@ -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. # SQL statement to check if we handle mail for a domain, either for users or aliases.
cat > /etc/postfix/virtual-mailbox-domains.cf << EOF; cat > /etc/postfix/virtual-mailbox-domains.cf << EOF;
dbpath=$db_path 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 EOF
# SQL statement to check if we handle mail for a user. # SQL statement to check if we handle mail for a user.
...@@ -129,7 +129,7 @@ EOF ...@@ -129,7 +129,7 @@ EOF
# postfix's preference for aliases for whole email addresses. # postfix's preference for aliases for whole email addresses.
cat > /etc/postfix/virtual-alias-maps.cf << EOF; cat > /etc/postfix/virtual-alias-maps.cf << EOF;
dbpath=$db_path 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 EOF
# Restart Services # Restart Services
......
...@@ -102,9 +102,56 @@ def migration_8(env): ...@@ -102,9 +102,56 @@ def migration_8(env):
os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private')) os.unlink(os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private'))
def migration_9(env): 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') 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"]) # Move the old aliases table to one side.
shell("check_call", ["sqlite3", db, "ALTER TABLE aliases ADD COLUMN applies_outbound INTEGER NOT NULL DEFAULT 1"]) 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(): def get_current_migration():
ver = 0 ver = 0
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment