1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2025-04-01 23:57:05 +00:00

Merge remote-tracking branch 'chadfurman/master' into chads-quota

# Conflicts:
#	management/daemon.py
#	management/mailconfig.py
#	management/templates/users.html
#	setup/bootstrap.sh
#	setup/mail-postfix.sh
#	setup/mail-users.sh
#	setup/migrate.py
This commit is contained in:
downtownallday 2024-09-06 12:03:08 -04:00
commit a6f69f297b
13 changed files with 379 additions and 57 deletions

View File

@ -18,12 +18,12 @@ objectIdentifier MiabLDAPmail MiabLDAProot:2
objectIdentifier MiabLDAPmailAttributeType MiabLDAPmail:1
objectIdentifier MiabLDAPmailObjectClass MiabLDAPmail:2
attributetype ( 1.3.6.1.4.1.15347.2.102
NAME 'transport'
attributetype ( 1.3.6.1.4.1.15347.2.102
NAME 'transport'
SUP name)
attributetype ( 1.3.6.1.4.1.15347.2.101
NAME 'mailRoutingAddress'
attributetype ( 1.3.6.1.4.1.15347.2.101
NAME 'mailRoutingAddress'
SUP mail )
attributetype ( 1.3.6.1.4.1.15347.2.110 NAME 'maildest'
@ -56,13 +56,31 @@ attributetype ( MiabLDAPmailAttributeType:1 NAME 'mailMember' DESC 'RFC6532 utf8
# create a utf8 version of core 'domainComponent'
attributetype ( MiabLDAPmailAttributeType:2 NAME 'dcIntl' DESC 'UTF8 domain component' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
# create a mda/lda user mailbox quota (for dovecot)
# format: number | number 'K' | number 'M' | number 'G'
attributetype ( MiabLDAPmailAttributeType:3
DESC 'MDA/LDA user mailbox quota'
NAME 'mailboxQuota'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE
EQUALITY caseExactMatch )
# dovecot supports more than one quota rule (but no way to use a
# multi-valued attribute). add a second attribute for a second quota
# rule even though we're not using more than one anticipating that we
# might in the future and avoid a schema update
attributetype ( MiabLDAPmailAttributeType:4
DESC 'MDA/LDA user mailbox quota 2'
NAME 'mailboxQuota2'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE
EQUALITY caseExactMatch )
objectclass ( 1.3.6.1.4.1.15347.2.1
NAME 'mailUser'
DESC 'E-Mail User'
SUP top
AUXILIARY
MUST ( uid $ mail $ maildrop )
MAY ( cn $ mailbox $ maildest $ mailaccess )
MAY ( cn $ mailbox $ maildest $ mailaccess $ mailboxQuota )
)
objectclass ( 1.3.6.1.4.1.15347.2.2

View File

@ -18,13 +18,13 @@
import sys, getpass, urllib.request, urllib.error, json, csv
import contextlib
def mgmt(cmd, data=None, is_json=False):
def mgmt(cmd, data=None, is_json=False, method='GET'):
# The base URL for the management daemon. (Listens on IPv4 only.)
mgmt_uri = 'http://127.0.0.1:10222'
setup_key_auth(mgmt_uri)
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None, method=method)
try:
response = urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
@ -74,6 +74,7 @@ if len(sys.argv) < 2:
{cli} user password user@domain.com [password]
{cli} user remove user@domain.com
{cli} user make-admin user@domain.com
{cli} user quota user@domain [new-quota] (get or set user quota)
{cli} user remove-admin user@domain.com
{cli} user admins (lists admins)
{cli} user mfa show user@domain.com (shows MFA devices for user, if any)
@ -97,6 +98,10 @@ elif sys.argv[1] == "user" and len(sys.argv) == 2:
print(user['email'], end='')
if "admin" in user['privileges']:
print("*", end='')
if user['quota'] == '0':
print(" unlimited", end='')
else:
print(" " + user['quota'], end='')
print()
elif sys.argv[1] == "user" and sys.argv[2] in {"add", "password"}:
@ -126,6 +131,14 @@ elif sys.argv[1] == "user" and sys.argv[2] == "admins":
if "admin" in user['privileges']:
print(user['email'])
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 4:
# Get a user's quota
print(mgmt("/mail/users/quota?text=1&email=%s" % sys.argv[3]))
elif sys.argv[1] == "user" and sys.argv[2] == "quota" and len(sys.argv) == 5:
# Set a user's quota
users = mgmt("/mail/users/quota", { "email": sys.argv[3], "quota": sys.argv[4] }, method='POST')
elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
# Show MFA status for a user.
status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True)
@ -150,4 +163,3 @@ elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
else:
print("Invalid command-line arguments.")
sys.exit(1)

View File

@ -30,6 +30,7 @@ import auth, utils
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, set_mail_display_name, remove_mail_user
from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege
from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
from mailconfig import get_mail_quota, set_mail_quota
from mfa import get_public_mfa_state, enable_mfa, disable_mfa
import mfa_totp
import contextlib
@ -201,8 +202,31 @@ def mail_users():
@app.route('/mail/users/add', methods=['POST'])
@authorized_personnel_only
def mail_users_add():
quota = request.form.get('quota', '0')
try:
return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), request.form.get('display_name', ''), env)
return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), quota, request.form.get('display_name', ''), env)
except ValueError as e:
return (str(e), 400)
@app.route('/mail/users/quota', methods=['GET'])
@authorized_personnel_only
def get_mail_users_quota():
email = request.values.get('email', '')
quota = get_mail_quota(email, env)
if request.values.get('text'):
return quota
return json_response({
"email": email,
"quota": quota
})
@app.route('/mail/users/quota', methods=['POST'])
@authorized_personnel_only
def mail_users_quota():
try:
return set_mail_quota(request.form.get('email', ''), request.form.get('quota'), env)
except ValueError as e:
return (str(e), 400)

View File

@ -19,8 +19,11 @@
# Python 3 in setup/questions.sh to validate the email
# address entered by the user.
import subprocess, shutil, os, sqlite3, re, ldap3, uuid, hashlib
import utils, backend
import os, sqlite3, re
import subprocess
import ldap3, uuid, hashlib, backend
import utils
from email_validator import validate_email as validate_email_, EmailNotValidError
import idna
import socket
@ -283,6 +286,18 @@ def get_mail_users(env, as_map=False, map_by="maildrop"):
return utils.sort_email_addresses(users, env)
def sizeof_fmt(num):
for unit in ['','K','M','G','T']:
if abs(num) < 1024.0:
if abs(num) > 99:
return "%3.0f%s" % (num, unit)
else:
return "%2.1f%s" % (num, unit)
num /= 1024.0
return str(num)
def get_mail_users_ex(env, with_archived=False):
# Returns a complex data structure of all user accounts, optionally
# including archived (status="inactive") accounts.
@ -307,20 +322,52 @@ def get_mail_users_ex(env, with_archived=False):
users = []
active_accounts = set()
c = open_database(env)
response = c.wait( c.search(env.LDAP_USERS_BASE, "(objectClass=mailUser)", attributes=['mail','maildrop','mailaccess','cn']) )
response = c.wait( c.search(env.LDAP_USERS_BASE, "(objectClass=mailUser)", attributes=['mail','maildrop','mailaccess','mailboxQuota','cn']) )
for rec in response:
#email = rec['maildrop'][0]
email = rec['mail'][0]
privileges = rec['mailaccess']
quota = rec['mailboxQuota'][0] if len(rec['mailboxQuota']>0) else '0'
display_name = rec['cn'][0]
active_accounts.add(email)
(user, domain) = email.split('@')
box_size = 0
box_quota = 0
percent = ''
try:
dirsize_file = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes/%s/%s/maildirsize' % (domain, user))
with open(dirsize_file, 'r') as f:
box_quota = int(f.readline().split('S')[0])
for line in f.readlines():
(size, count) = line.split(' ')
box_size += int(size)
try:
percent = (box_size / box_quota) * 100
except:
percent = 'Error'
except:
box_size = '?'
box_quota = '?'
percent = '?'
if quota == '0':
percent = ''
user = {
"email": email,
"privileges": privileges,
"quota": quota,
"status": "active",
"display_name": display_name
"display_name": display_name,
"box_quota": box_quota,
"box_size": sizeof_fmt(box_size) if box_size != '?' else box_size,
"percent": '%3.0f%%' % percent if type(percent) != str else percent,
}
users.append(user)
# Add in archived accounts.
@ -338,6 +385,9 @@ def get_mail_users_ex(env, with_archived=False):
"status": "inactive",
"mailbox": mbox,
"display_name": ""
"box_size": '?',
"box_quota": '?',
"percent": '?',
}
users.append(user)
@ -697,12 +747,13 @@ def remove_mail_domain(env, domain_idna, validate=True):
return True
def add_mail_user(email, pw, privs, display_name, env):
def add_mail_user(email, pw, privs, quota, display_name, env):
# Add a new mail user.
#
# email: the new user's email address (idna)
# pw: the new user's password
# privs: either an array of privilege strings, or a newline
# quota: a string (number | number 'M' | number 'G') or None
# separated string of privilege names
# display_name: a string with users givenname and surname (eg "Al Woods")
#
@ -735,6 +786,14 @@ def add_mail_user(email, pw, privs, display_name, env):
validation = validate_privilege(p)
if validation: return validation
if quota is None:
quota = '0'
try:
quota = validate_quota(quota)
except ValueError as e:
return (str(e), 400)
# get the database
conn = open_database(env)
@ -773,6 +832,7 @@ def add_mail_user(email, pw, privs, display_name, env):
"maildrop" : email.lower(),
"uid" : uid,
"mailaccess": privs,
"mailboxQuota": quota,
"cn": cn,
"sn": sn,
"shadowLastChange": backend.get_shadowLastChanged()
@ -805,6 +865,8 @@ def add_mail_user(email, pw, privs, display_name, env):
# convert alias's mailMember to member
convert_mailMember(env, conn, dn, email)
dovecot_quota_recalc(email)
# Update things in case any new domains are added.
if domain_added:
return kick(env, return_status)
@ -867,6 +929,53 @@ def validate_login(email, pw, env):
return False
def get_mail_quota(email, env):
user = find_mail_user(env, email, ['mailboxQuota'])
if user is None:
return ("That's not a user (%s)." % email, 400)
if len(user['mailboxQuota'])==0:
return '0'
else:
return user['mailboxQuota'][0]
def set_mail_quota(email, quota, env):
# validate that password is acceptable
quota = validate_quota(quota)
# update the database
conn = open_database(env)
user = find_mail_user(env, email, ['mailboxQuota'], conn)
if user is None:
return ("That's not a user (%s)." % email, 400)
conn.modify_record(user, { 'mailboxQuota': quota })
dovecot_quota_recalc(email)
return "OK"
def dovecot_quota_recalc(email):
# dovecot processes running for the user will not recognize the new quota setting
# a reload is necessary to reread the quota setting, but it will also shut down
# running dovecot processes. Email clients generally log back in when they lose
# a connection.
# subprocess.call(['doveadm', 'reload'])
# force dovecot to recalculate the quota info for the user.
subprocess.call(["doveadm", "quota", "recalc", "-u", email])
def validate_quota(quota):
# validate quota
quota = quota.strip().upper()
if quota == "":
raise ValueError("No quota provided.")
if re.search(r"[\s,.]", quota):
raise ValueError("Quotas cannot contain spaces, commas, or decimal points.")
if not re.match(r'^[\d]+[GMK]?$', quota):
raise ValueError("Invalid quota.")
return quota
def get_mail_password(email, env):
# Gets the hashed passwords for a user. In ldap, userPassword is
# multi-valued and each value can have different hash. This

View File

@ -6,6 +6,7 @@
#user_table .account_inactive .if_active { display: none; }
#user_table .account_active .if_inactive { display: none; }
#user_table .account_active.if_inactive { display: none; }
.row-center { text-align: center; }
</style>
<h3>Add a mail user</h3>
@ -31,11 +32,13 @@
</select>
</div>
<div class="form-group">
<label class="sr-only" for="adduserQuota">Quota</label>
<input type="text" class="form-control" id="adduserQuota" placeholder="Quota" style="width:5em;" value="0">
<div>Display Name</div>
<input id="adduserDisplayName" class="form-control" type="text" placeholder="eg: John Smith">
</div>
<div class="text-center">
<div>&nbsp;</div>
<div>&nbsp;</div>
<button type="submit" class="btn btn-primary">Add User</button>
</div>
</form>
@ -44,13 +47,17 @@
<li>Use <a href="#aliases">aliases</a> to create email addresses that forward to existing accounts.</li>
<li>Administrators get access to this control panel.</li>
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="#aliases">aliases</a> can.</li>
<li>Quotas may not contain any spaces, commas or decimal points. Suffixes of G (gigabytes) and M (megabytes) are allowed. For unlimited storage enter 0 (zero)</li>
</ul>
<h3>Existing mail users</h3>
<table id="user_table" class="table" style="width: auto">
<thead>
<tr>
<th width="50%">Email Address</th>
<th width="35%">Email Address</th>
<th class="row-center">Size</th>
<th class="row-center">Used</th>
<th class="row-center">Quota</th>
<th>Actions</th>
</tr>
</thead>
@ -64,10 +71,21 @@
<td>
<span class="address"></span> <span class="display_name_wrapper">(<a class="display_name" href="#" onclick="users_set_displayname(this); return false;" title="Change display name"></a>)</span>
</td>
<td class="box-size row-center"></td>
<td class="percent row-center"></td>
<td class="quota row-center">
</td>
<td class='actions'>
<span class='privs'>
</span>
<span class="if_active">
<a href="#" onclick="users_set_quota(this); return false;" class='setquota' title="Set Quota">
set quota
</a>
|
</span>
<span class="if_active">
<a href="#" onclick="users_set_password(this); return false;" class='setpw' title="Set Password">
set password
@ -108,10 +126,28 @@
<table class="table" style="margin-top: .5em">
<thead><th>Verb</th> <th>Action</th><th></th></thead>
<tr><td>GET</td><td><i>(none)</i></td> <td>Returns a list of existing mail users. Adding <code>?format=json</code> to the URL will give JSON-encoded results.</td></tr>
<tr><td>POST</td><td>/add</td> <td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>.</td></tr>
<tr><td>POST</td><td>/remove</td> <td>Removes a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
<tr>
<td>POST</td>
<td>/add</td>
<td>Adds a new mail user. Required POST-body parameters are <code>email</code> and <code>password</code>. Optional parameters: <code>privilege=admin</code> and <code>quota</code></td>
</tr>
<tr>
<td>POST</td>
<td>/remove</td>
<td>Removes a mail user. Required POST-by parameter is <code>email</code>.</td>
</tr>
<tr><td>POST</td><td>/privileges/add</td> <td>Used to make a mail user an admin. Required POST-body parameters are <code>email</code> and <code>privilege=admin</code>.</td></tr>
<tr><td>POST</td><td>/privileges/remove</td> <td>Used to remove the admin privilege from a mail user. Required POST-body parameter is <code>email</code>.</td></tr>
<tr>
<td>GET</td>
<td>/quota</td>
<td>Get the quota for a mail user. Required POST-body parameters are <code>email</code> and will return JSON result</td>
</tr>
<tr>
<td>POST</td>
<td>/quota</td>
<td>Set the quota for a mail user. Required POST-body parameters are <code>email</code> and <code>quota</code>.</td>
</tr>
</table>
<h4>Examples:</h4>
@ -144,7 +180,7 @@ function show_users() {
function(r) {
$('#user_table tbody').html("");
for (var i = 0; i < r.length; i++) {
var hdr = $("<tr><th colspan='2' style='background-color: #EEE'></th></tr>");
var hdr = $("<tr><th colspan='6' style='background-color: #EEE'></th></tr>");
hdr.find('th').text(r[i].domain);
$('#user_table tbody').append(hdr);
@ -162,7 +198,14 @@ function show_users() {
n2.addClass("account_" + user.status);
n.attr('data-email', user.email);
n.attr('data-quota', user.quota);
n.find('.address').text(user.email);
n.find('.box-size').text(user.box_size);
if (user.box_size == '?') {
n.find('.box-size').attr('title', 'Mailbox size is unkown')
}
n.find('.percent').text(user.percent);
n.find('.quota').text((user.quota == '0') ? 'unlimited' : user.quota);
if (user.status == "inactive") {
n.find('.display_name_wrapper').text('[archived]');
}
@ -197,6 +240,7 @@ function do_add_user() {
var email = $("#adduserEmail").val();
var pw = $("#adduserPassword").val();
var privs = $("#adduserPrivs").val();
var quota = $("#adduserQuota").val();
var display_name = $("#adduserDisplayName").val();
api(
"/mail/users/add",
@ -205,6 +249,7 @@ function do_add_user() {
email: email,
password: pw,
privileges: privs,
quota: quota
display_name: display_name
},
function(r) {
@ -272,6 +317,36 @@ function users_set_displayname(elem) {
});
}
function users_set_quota(elem) {
var email = $(elem).parents('tr').attr('data-email');
var quota = $(elem).parents('tr').attr('data-quota');
show_modal_confirm(
"Set Quota",
$("<p>Set quota for <b>" + email + "</b>?</p>" +
"<p>" +
"<label for='users_set_quota' style='display: block; font-weight: normal'>Quota:</label>" +
"<input type='text' id='users_set_quota' value='" + quota + "'></p>" +
"<p><small>Quotas may not contain any spaces or commas. Suffixes of G (gigabytes) and M (megabytes) are allowed.</small></p>" +
"<p><small>For unlimited storage enter 0 (zero)</small></p>"),
"Set Quota",
function() {
api(
"/mail/users/quota",
"POST",
{
email: email,
quota: $('#users_set_quota').val()
},
function(r) {
show_users();
},
function(r) {
show_modal_error("Set Quota", r);
});
});
}
function users_remove(elem) {
var email = $(elem).parents('tr').attr('data-email');
@ -337,7 +412,7 @@ function generate_random_password() {
var charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; // confusable characters skipped
for (var i = 0; i < 12; i++)
pw += charset.charAt(Math.floor(Math.random() * charset.length));
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></pr");
show_modal_error("Random Password", "<p>Here, try this:</p> <p><code style='font-size: 110%'>" + pw + "</code></p>");
return false; // cancel click
}
</script>

View File

@ -133,7 +133,7 @@ fi
if [ -z "${ENCRYPTION_AT_REST:-}" ]; then
source ehdd/ehdd_funcs.sh || exit 1
hdd_exists && ENCRYPTION_AT_REST=true
elif [ "${ENCRYPTION_AT_REST:-}" = "false" ]; then
elif [ "${ENCRYPTION_AT_REST:-}" = "false" ]; then
source ehdd/ehdd_funcs.sh || exit 1
if hdd_exists; then
echo "Encryption-at-rest must be disabled manually"
@ -147,4 +147,3 @@ if [ "${ENCRYPTION_AT_REST:-false}" = "true" ]; then
else
setup/start.sh </dev/tty
fi

View File

@ -99,7 +99,7 @@ create_miab_conf() {
_add_if_missing "${prefix}_DN" "cn=$cn,$LDAP_SERVICES_BASE"
_add_if_missing "${prefix}_PASSWORD" "$(generate_password 64)"
done
chmod 0640 "$MIAB_INTERNAL_CONF_FILE"
. "$MIAB_INTERNAL_CONF_FILE"
}
@ -126,7 +126,7 @@ create_service_accounts() {
# create service accounts. service accounts have special access
# rights, generally read-only to users, aliases, and configuration
# subtrees (see apply_access_control)
local prefix dn pass
for prefix in ${SERVICE_ACCOUNTS[*]}
do
@ -147,7 +147,7 @@ userPassword: $(slappasswd_hash "$pass")
EOF
fi
done
}
@ -155,7 +155,7 @@ install_system_packages() {
# install required deb packages, generate admin credentials
# and apply them to the installation
create_miab_conf
# Set installation defaults to avoid interactive dialogs. See
# /var/lib/dpkg/info/slapd.templates for a list of what can be set
debconf-set-selections <<EOF
@ -164,10 +164,10 @@ slapd slapd/domain string ${LDAP_DOMAIN}
slapd slapd/password1 password ${LDAP_ADMIN_PASSWORD}
slapd slapd/password2 password ${LDAP_ADMIN_PASSWORD}
EOF
# Install packages
say "Installing OpenLDAP server..."
# we must install slapd without DEBIAN_FRONTEND=noninteractive or
# debconf selections are ignored
hide_output apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confnew" install slapd
@ -227,7 +227,7 @@ EOF
say " is set to: $ATTR_VALUE"
say " expected : $LDAP_ADMIN_DN"
die
fi
fi
}
relocate_slapd_data() {
@ -277,11 +277,11 @@ relocate_slapd_data() {
say_verbose " DB='${DB_DIR}'"
say_verbose " to:"
say_verbose " CONF=${MIAB_SLAPD_CONF}"
say_verbose " DB=${MIAB_SLAPD_DB_DIR}"
say_verbose " DB=${MIAB_SLAPD_DB_DIR}"
say_verbose ""
say_verbose "Stopping slapd"
systemctl stop slapd || die "Could not stop slapd"
# Modify the path to dc=mailinabox's database directory
say_verbose "Dump config database"
local TMP="/tmp/miab_relocate_ldap.ldif"
@ -320,7 +320,7 @@ schema_to_ldif() {
cat="curl -s"
fi
fi
cat >"$ldif" <<EOF
dn: cn=$cn,cn=schema,cn=config
objectClass: olcSchemaConfig
@ -358,7 +358,7 @@ EOF
add_schemas() {
# Add necessary schema's for MiaB operaion
# Add necessary schema's for MiaB operaion
#
# Note: the postfix schema originally came from the ldapadmin
# project (GPL)(*), but has been modified to support the needs of
@ -385,7 +385,7 @@ add_schemas() {
ldapadd -Q -Y EXTERNAL -H ldapi:/// -f "$ldif" >/dev/null
rm -f "$ldif"
fi
done
done
}
@ -481,7 +481,7 @@ EOF
add_overlays() {
# Apply slapd overlays - apply the commonly used member-of overlay
# now because adding it later is harder.
# Get the config dn for the database
get_attribute "cn=config" "olcSuffix=${LDAP_BASE}" "dn"
[ -z "$ATTR_DN" ] &&
@ -498,7 +498,7 @@ add: olcModuleLoad
olcModuleLoad: memberof.la
EOF
fi
get_attribute "$cdn" "(olcOverlay=memberof)" "olcOverlay"
if [ -z "$ATTR_DN" ]; then
say_verbose "Adding memberof overlay to $LDAP_BASE"
@ -516,7 +516,7 @@ EOF
add_indexes() {
# Index mail-related attributes
# Get the config dn for the database
get_attribute "cn=config" "olcSuffix=${LDAP_BASE}" "dn"
[ -z "$ATTR_DN" ] &&
@ -678,7 +678,7 @@ EOF
#
process_cmdline() {
[ -e "$MIAB_INTERNAL_CONF_FILE" ] && . "$MIAB_INTERNAL_CONF_FILE"
if [ "$1" == "-d" ]; then
# Start slapd in interactive/debug mode
echo "!! SERVER DEBUG MODE !!"
@ -688,7 +688,7 @@ process_cmdline() {
echo "Listening on $SLAPD_SERVICES..."
/usr/sbin/slapd -h "$SLAPD_SERVICES" -g openldap -u openldap -F $MIAB_SLAPD_CONF -d ${2:-1}
exit 0
elif [ "$1" == "-config" ]; then
# Apply a certain configuration
if [ "$2" == "server" ]; then
@ -734,7 +734,7 @@ process_cmdline() {
local hide_attrs="(structuralObjectClass|entryUUID|creatorsName|createTimestamp|entryCSN|modifiersName|modifyTimestamp)"
local slapcat_args=(-F "$MIAB_SLAPD_CONF" -o ldif-wrap=no)
[ ${verbose:-0} -gt 0 ] && hide_attrs="(_____NEVERMATCHES)"
if [ "$s" == "all" ]; then
echo ""
echo '--------------------------------'
@ -777,7 +777,7 @@ process_cmdline() {
if [ "$s" == "permitted-senders" -o "$s" == "ps" ]; then
echo ""
echo '--------------------------------'
local attrs=(mail member mailRoutingAddress rfc822MailMember)
local attrs=(mail member mailRoutingAddress mailMember)
[ ${verbose:-0} -gt 0 ] && attrs=()
debug_search "(objectClass=mailGroup)" "$LDAP_PERMITTED_SENDERS_BASE" ${attrs[@]}
fi
@ -814,7 +814,7 @@ process_cmdline() {
rm -f "/etc/default/slapd"
echo "Done"
exit 0
elif [ ! -z "$1" ]; then
echo "Invalid command line argument '$1'"
exit 1

View File

@ -76,6 +76,32 @@ tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
# Create, subscribe, and mark as special folders: INBOX, Drafts, Sent, Trash, Spam and Archive.
cp conf/dovecot-mailboxes.conf /etc/dovecot/conf.d/15-mailboxes.conf
sed -i "s/#mail_plugins =\(.*\)/mail_plugins =\1 \$mail_plugins quota/" /etc/dovecot/conf.d/10-mail.conf
if ! grep -q "mail_plugins.* imap_quota" /etc/dovecot/conf.d/20-imap.conf; then
sed -i "s/\(mail_plugins =.*\)/\1\n mail_plugins = \$mail_plugins imap_quota/" /etc/dovecot/conf.d/20-imap.conf
fi
# configure stuff for quota support
if ! grep -q "quota_status_success = DUNNO" /etc/dovecot/conf.d/90-quota.conf; then
cat > /etc/dovecot/conf.d/90-quota.conf << EOF;
plugin {
quota = maildir
quota_grace = 10%
quota_status_success = DUNNO
quota_status_nouser = DUNNO
quota_status_overquota = "522 5.2.2 Mailbox is full"
}
service quota-status {
executable = quota-status -p postfix
inet_listener {
port = 12340
}
}
EOF
fi
# ### IMAP/POP

View File

@ -259,7 +259,7 @@ tools/editconf.py /etc/postfix/main.cf -e lmtp_destination_recipient_limit=
# "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC
tools/editconf.py /etc/postfix/main.cf \
smtpd_sender_restrictions="reject_non_fqdn_sender,reject_unknown_sender_domain,reject_authenticated_sender_login_mismatch,reject_rhsbl_sender dbl.spamhaus.org=127.0.1.[2..99]" \
smtpd_recipient_restrictions="permit_sasl_authenticated,permit_mynetworks,reject_rbl_client zen.spamhaus.org=127.0.0.[2..11],reject_unlisted_recipient,check_policy_service unix:private/policy-spf,check_policy_service inet:127.0.0.1:10023"
smtpd_recipient_restrictions="permit_sasl_authenticated,permit_mynetworks,reject_rbl_client zen.spamhaus.org=127.0.0.[2..11],reject_unlisted_recipient,check_policy_service unix:private/policy-spf,check_policy_service inet:127.0.0.1:10023,check_policy_service inet:127.0.0.1:12340"
# Postfix connects to Postgrey on the 127.0.0.1 interface specifically. Ensure that
# Postgrey listens on the same interface (and not IPv6, for instance).

View File

@ -89,7 +89,7 @@ pass_attrs = maildrop=user
# lmtp delivery, pass_filter is not used, and postfix has already
# rewritten the envelope using the maildrop address.
user_filter = (&(objectClass=mailUser)(|(mail=%u)(maildrop=%u)))
user_attrs = maildrop=user
user_attrs = maildrop=user mailboxQuota=quota_rule=*:bytes=%\$
# Account iteration for various dovecot tools (doveadm)
iterate_filter = (objectClass=mailUser)
@ -269,3 +269,6 @@ chmod 0640 /etc/postfix/virtual-alias-maps.cf
restart_service postfix
restart_service dovecot
# force a recalculation of all user quotas
doveadm quota recalc -A

View File

@ -200,6 +200,12 @@ def migration_14(env):
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);"])
def migration_15(env):
# Add a column to the users table to store their quota limit. Default to '0' for unlimited.
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "ALTER TABLE users ADD COLUMN quota TEXT NOT NULL DEFAULT '0';"])
###########################################################
@ -306,7 +312,7 @@ def migration_miabldap_2(env):
return ldif.replace("rfc822MailMember: ", "mailMember: ")
# apply schema changes miabldap/1 -> miabldap/2
ldap.unbind()
print("Apply schema changes")
print("Apply schema changes to support utf8 email addresses")
m14.apply_schema_changes(env, ldapvars, ldif_change_fn)
# reconnect
ldap = connect(ldapvars)
@ -329,6 +335,52 @@ def migration_miabldap_2(env):
ldap.unbind()
def migration_miabldap_3(env):
# This migration step changes the ldap schema to support quotas
#
# possible states at this point:
# miabldap was installed and is being upgraded
# -> schema update needed
# a miab install was present and step 1 upgaded it to miabldap
# -> new schema already present
#
sys.path.append(os.path.realpath(os.path.join(os.path.dirname(__file__), "../management")))
import ldap3
from backend import connect
import migration_14 as m14
# 1. get ldap site details
ldapvars = load_env_vars_from_file(os.path.join(env["STORAGE_ROOT"], "ldap/miab_ldap.conf"), strip_quotes=True)
# connect before schema changes to ensure admin password works
ldap = connect(ldapvars)
# 2. if this is a miab -> maibldap install, the new schema is
# already in place and no schema changes are needed. however,
# if this is a miabldap/1 to miabldap/2 migration, we must
# upgrade the schema.
ret = shell("check_output", [
"ldapsearch",
"-Q",
"-Y", "EXTERNAL",
"-H", "ldapi:///",
"(&(objectClass=olcSchemaConfig)(cn={*}postfix))",
"-b", "cn=schema,cn=config",
"-o", "ldif_wrap=no",
"-LLL",
"olcObjectClasses"
])
ldap.unbind()
if "mailboxQuota" not in ret:
def ldif_change_fn(ldif):
# the schema change we're making does not require any data changes
return ldif
# apply schema changes miabldap/2 -> miabldap/3
print("Apply schema changes to support mailbox quotas")
m14.apply_schema_changes(env, ldapvars, ldif_change_fn)
def get_current_migration():
ver = 0

View File

@ -17,7 +17,7 @@
import uuid, os, sqlite3, ldap3, hashlib
def add_user(env, ldapconn, search_base, users_base, domains_base, email, password, privs, totp, cn=None):
def add_user(env, ldapconn, search_base, users_base, domains_base, email, password, privs, quota, totp, cn=None):
# Add a sqlite user to ldap
# env are the environment variables
# ldapconn is the bound ldap connection
@ -27,6 +27,7 @@ def add_user(env, ldapconn, search_base, users_base, domains_base, email, passwo
# email is the user's email
# password is the user's current sqlite password hash
# privs is an array of privilege names for the user
# quota is the users mailbox quota (string; defaults to '0')
# totp contains the list of secrets, mru tokens, and labels
# cn is the user's common name [optional]
#
@ -45,13 +46,14 @@ def add_user(env, ldapconn, search_base, users_base, domains_base, email, passwo
m = hashlib.sha1()
m.update(bytearray(email.lower(),'utf-8'))
uid = m.hexdigest()
# Attributes to apply to the new ldap entry
objectClasses = [ 'inetOrgPerson','mailUser','shadowAccount' ]
attrs = {
"mail" : email,
"maildrop" : email,
"uid" : uid,
"mailboxQuota": quota,
# Openldap uses prefix {CRYPT} for all crypt(3) formats
"userPassword" : password.replace('{SHA512-CRYPT}','{CRYPT}')
}
@ -91,10 +93,10 @@ def add_user(env, ldapconn, search_base, users_base, domains_base, email, passwo
attrs['totpMruToken'] = totp["mru_token"]
attrs['totpMruTokenTime'] = totp["mru_token_time"]
attrs['totpLabel'] = totp["label"]
# Add user
dn = "uid=%s,%s" % (uid, users_base)
print("adding user %s" % email)
ldapconn.add(dn, objectClasses, attrs)
@ -116,14 +118,15 @@ def create_users(env, conn, ldapconn, ldap_base, ldap_users_base, ldap_domains_b
# select users
c = conn.cursor()
c.execute("SELECT id, email, password, privileges from users")
c.execute("SELECT id, email, password, privileges, quota from users")
users = {}
for row in c:
user_id=row[0]
email=row[1]
password=row[2]
privs=row[3]
privs=row[3]
quota=row[4]
totp = None
c2 = conn.cursor()
@ -143,7 +146,7 @@ def create_users(env, conn, ldapconn, ldap_base, ldap_users_base, ldap_domains_b
totp["label"].append("{%s}%s" % (rowidx, row2[2] or ''))
rowidx += 1
dn = add_user(env, ldapconn, ldap_base, ldap_users_base, ldap_domains_base, email, password, privs.split("\n"), totp)
dn = add_user(env, ldapconn, ldap_base, ldap_users_base, ldap_domains_base, email, password, privs.split("\n"), quota, totp)
users[email] = dn
return users
@ -164,7 +167,7 @@ def create_aliases(env, conn, ldapconn, aliases_base):
cn="%s" % uuid.uuid4()
dn="cn=%s,%s" % (cn, aliases_base)
description="Mail group %s" % alias
if alias.startswith("postmaster@") or \
alias.startswith("hostmaster@") or \
alias.startswith("abuse@") or \
@ -172,7 +175,7 @@ def create_aliases(env, conn, ldapconn, aliases_base):
alias == "administrator@" + env['PRIMARY_HOSTNAME']:
description = "Required alias"
print("adding alias %s" % alias)
print("adding alias %s" % alias)
ldapconn.add(dn, ['mailGroup'], {
"mail": alias,
"description": description
@ -196,7 +199,7 @@ def populate_aliases(conn, ldapconn, users_map, aliases_map):
alias_dn=aliases_map[alias]
members = []
mailMembers = []
for email in row[1].split(','):
email=email.strip()
if email=="":
@ -207,13 +210,13 @@ def populate_aliases(conn, ldapconn, users_map, aliases_map):
members.append(aliases_map[email])
else:
mailMembers.append(email)
print("populate alias group %s" % alias)
changes = {}
if len(members)>0:
changes["member"]=[(ldap3.MODIFY_REPLACE, members)]
if len(mailMembers)>0:
changes["rfc822MailMember"]=[(ldap3.MODIFY_REPLACE, mailMembers)]
changes["rfc822MailMember"]=[(ldap3.MODIFY_REPLACE, mailMembers)]
ldapconn.modify(alias_dn, changes)

View File

@ -214,6 +214,7 @@ cat > $RCM_CONFIG <<EOF;
/* prevent CSRF, requires php 7.3+ */
\$config['session_samesite'] = 'Strict';
\$config['quota_zero_as_unlimited'] = true;
?>
EOF