diff --git a/conf/schema/postfix.schema b/conf/schema/postfix.schema
index 8056998e..19679ce8 100644
--- a/conf/schema/postfix.schema
+++ b/conf/schema/postfix.schema
@@ -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
diff --git a/management/cli.py b/management/cli.py
index 06b10f91..28aa3912 100755
--- a/management/cli.py
+++ b/management/cli.py
@@ -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)
-
diff --git a/management/daemon.py b/management/daemon.py
index 3048ea29..1b521b06 100755
--- a/management/daemon.py
+++ b/management/daemon.py
@@ -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)
diff --git a/management/mailconfig.py b/management/mailconfig.py
index c5ad39e6..3404ae87 100755
--- a/management/mailconfig.py
+++ b/management/mailconfig.py
@@ -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
diff --git a/management/templates/users.html b/management/templates/users.html
index 1dca56cb..84c65e99 100644
--- a/management/templates/users.html
+++ b/management/templates/users.html
@@ -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; }
Add a mail user
@@ -31,11 +32,13 @@
+
+
Display Name
-
+
@@ -44,13 +47,17 @@
Use aliases to create email addresses that forward to existing accounts.
Administrators get access to this control panel.
User accounts cannot contain any international (non-ASCII) characters, but aliases can.
+
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)