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:
commit
a6f69f297b
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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> </div>
|
||||
<div> </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>
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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).
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user