diff --git a/conf/dovecot/conf.d/15-mailboxes.conf b/conf/dovecot/conf.d/15-mailboxes.conf
new file mode 100644
index 00000000..58e2efed
--- /dev/null
+++ b/conf/dovecot/conf.d/15-mailboxes.conf
@@ -0,0 +1,63 @@
+## NOTE: This file is automatically generated by Mail-in-a-Box.
+## Do not edit this file. It is continually updated by
+## Mail-in-a-Box and your changes will be lost.
+##
+## Mail-in-a-Box machines are not meant to be modified.
+## If you modify any system configuration you are on
+## your own --- please do not ask for help from us.
+
+namespace inbox {
+ # Automatically create & subscribe some folders.
+ # * Create and subscribe the INBOX folder.
+ # * Our sieve rule for spam expects that the Spam folder exists.
+ # * Z-Push must be configured with the same settings in conf/zpush/backend_imap.php (#580).
+
+ # MUA notes:
+ # * Roundcube will show an error if the user tries to delete a message before the Trash folder exists (#359).
+ # * K-9 mail will poll every 90 seconds if a Drafts folder does not exist.
+ # * Apple's OS X Mail app will create 'Sent Messages' if it doesn't see a folder with the \Sent flag (#571, #573) and won't be able to archive messages unless 'Archive' exists (#581).
+ # * Thunderbird's default in its UI is 'Archives' (plural) but it will configure new accounts to use whatever we say here (#581).
+
+ # auto:
+ # 'create' will automatically create this mailbox.
+ # 'subscribe' will both create and subscribe to the mailbox.
+
+ # special_use is a space separated list of IMAP SPECIAL-USE
+ # attributes as specified by RFC 6154:
+ # \All \Archive \Drafts \Flagged \Junk \Sent \Trash
+
+ mailbox INBOX {
+ auto = subscribe
+ }
+ mailbox Spam {
+ special_use = \Junk
+ auto = subscribe
+ }
+ mailbox Drafts {
+ special_use = \Drafts
+ auto = subscribe
+ }
+ mailbox Sent {
+ special_use = \Sent
+ auto = subscribe
+ }
+ mailbox Trash {
+ special_use = \Trash
+ auto = subscribe
+ }
+ mailbox Archive {
+ special_use = \Archive
+ auto = subscribe
+ }
+
+ # dovevot's standard mailboxes configuration file marks two sent folders
+ # with the \Sent attribute, just in case clients don't agree about which
+ # they're using. We'll keep that, plus add Junk as an alterative for Spam.
+ # These are not auto-created.
+ mailbox "Sent Messages" {
+ special_use = \Sent
+ }
+ mailbox Junk {
+ special_use = \Junk
+ }
+}
diff --git a/management/cli.py b/management/cli.py
index 70dbe894..1c3fa4bd 100755
--- a/management/cli.py
+++ b/management/cli.py
@@ -60,11 +60,13 @@ def setup_key_auth(mgmt_uri):
if len(sys.argv) < 2:
print("""Usage:
+ {cli} system default-quota [new default] (set default quota for system)
{cli} user (lists users)
{cli} user add user@domain.com [password]
{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]
{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)
@@ -88,6 +90,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"}:
@@ -117,6 +123,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:
+ # Set 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] })
+
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)
@@ -138,6 +152,12 @@ elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
+elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(sys.argv) == 3:
+ print(mgmt("/system/default-quota?text=1"))
+
+elif sys.argv[1] == "system" and sys.argv[2] == "default-quota" and len(sys.argv) == 4:
+ print(mgmt("/system/default-quota", { "default_quota": sys.argv[3]}))
+
else:
print("Invalid command-line arguments.")
sys.exit(1)
diff --git a/management/daemon.py b/management/daemon.py
index 3aa6eed2..e850e6c6 100755
--- a/management/daemon.py
+++ b/management/daemon.py
@@ -21,6 +21,7 @@ import auth, utils
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, 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, get_default_quota, validate_quota
from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
import contextlib
@@ -191,8 +192,31 @@ def mail_users():
@app.route('/mail/users/add', methods=['POST'])
@authorized_personnel_only
def mail_users_add():
+ quota = request.form.get('quota', get_default_quota(env))
try:
- return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), env)
+ return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), quota, 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)
@@ -651,6 +675,29 @@ def privacy_status_set():
utils.write_settings(config, env)
return "OK"
+@app.route('/system/default-quota', methods=["GET"])
+@authorized_personnel_only
+def default_quota_get():
+ if request.values.get('text'):
+ return get_default_quota(env)
+ else:
+ return json_response({
+ "default-quota": get_default_quota(env),
+ })
+
+@app.route('/system/default-quota', methods=["POST"])
+@authorized_personnel_only
+def default_quota_set():
+ config = utils.load_settings(env)
+ try:
+ config["default-quota"] = validate_quota(request.values.get('default_quota'))
+ utils.write_settings(config, env)
+
+ except ValueError as e:
+ return ("ERROR: %s" % str(e), 400)
+
+ return "OK"
+
# MUNIN
@app.route('/munin/')
diff --git a/management/mailconfig.py b/management/mailconfig.py
index 39ca1b6e..f9c8f237 100755
--- a/management/mailconfig.py
+++ b/management/mailconfig.py
@@ -102,6 +102,18 @@ def get_mail_users(env):
users = [ row[0] for row in c.fetchall() ]
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.
@@ -125,13 +137,46 @@ def get_mail_users_ex(env, with_archived=False):
users = []
active_accounts = set()
c = open_database(env)
- c.execute('SELECT email, privileges FROM users')
- for email, privileges in c.fetchall():
+ c.execute('SELECT email, privileges, quota FROM users')
+ for email, privileges, quota in c.fetchall():
active_accounts.add(email)
+ (user, domain) = email.split('@')
+ box_size = 0
+ box_count = 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)
+ box_count += int(count)
+
+ try:
+ percent = (box_size / box_quota) * 100
+ except:
+ percent = 'Error'
+
+ except:
+ box_size = '?'
+ box_count = '?'
+ box_quota = '?'
+ percent = '?'
+
+ if quota == '0':
+ percent = ''
+
user = {
"email": email,
"privileges": parse_privs(privileges),
+ "quota": quota,
+ "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,
+ "box_count": box_count,
"status": "active",
}
users.append(user)
@@ -150,6 +195,10 @@ def get_mail_users_ex(env, with_archived=False):
"privileges": [],
"status": "inactive",
"mailbox": mbox,
+ "box_count": '?',
+ "box_size": '?',
+ "box_quota": '?',
+ "percent": '?',
}
users.append(user)
@@ -266,7 +315,7 @@ def get_mail_domains(env, filter_aliases=lambda alias : True, users_only=False):
domains.extend([get_domain(address, as_unicode=False) for address, _, _, auto in get_mail_aliases(env) if filter_aliases(address) and not auto ])
return set(domains)
-def add_mail_user(email, pw, privs, env):
+def add_mail_user(email, pw, privs, quota, env):
# validate email
if email.strip() == "":
return ("No email address provided.", 400)
@@ -292,6 +341,14 @@ def add_mail_user(email, pw, privs, env):
validation = validate_privilege(p)
if validation: return validation
+ if quota is None:
+ quota = get_default_quota()
+
+ try:
+ quota = validate_quota(quota)
+ except ValueError as e:
+ return (str(e), 400)
+
# get the database
conn, c = open_database(env, with_connection=True)
@@ -300,14 +357,16 @@ def add_mail_user(email, pw, privs, env):
# add the user to the database
try:
- c.execute("INSERT INTO users (email, password, privileges) VALUES (?, ?, ?)",
- (email, pw, "\n".join(privs)))
+ c.execute("INSERT INTO users (email, password, privileges, quota) VALUES (?, ?, ?, ?)",
+ (email, pw, "\n".join(privs), quota))
except sqlite3.IntegrityError:
return ("User already exists.", 400)
# write databasebefore next step
conn.commit()
+ dovecot_quota_recalc(email)
+
# Update things in case any new domains are added.
return kick(env, "mail user added")
@@ -332,6 +391,59 @@ def hash_password(pw):
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
+
+def get_mail_quota(email, env):
+ conn, c = open_database(env, with_connection=True)
+ c.execute("SELECT quota FROM users WHERE email=?", (email,))
+ rows = c.fetchall()
+ if len(rows) != 1:
+ return ("That's not a user (%s)." % email, 400)
+
+ return rows[0][0]
+
+
+def set_mail_quota(email, quota, env):
+ # validate that password is acceptable
+ quota = validate_quota(quota)
+
+ # update the database
+ conn, c = open_database(env, with_connection=True)
+ c.execute("UPDATE users SET quota=? WHERE email=?", (quota, email))
+ if c.rowcount != 1:
+ return ("That's not a user (%s)." % email, 400)
+ conn.commit()
+
+ 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 get_default_quota(env):
+ config = utils.load_settings(env)
+ return config.get("default-quota", '0')
+
+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]+[GM]?$', quota):
+ raise ValueError("Invalid quota.")
+
+ return quota
+
def get_mail_password(email, env):
# Gets the hashed password for a user. Passwords are stored in Dovecot's
# password format, with a prefixed scheme.
diff --git a/management/status_checks.py b/management/status_checks.py
index 51f8e631..0ba9c3c5 100755
--- a/management/status_checks.py
+++ b/management/status_checks.py
@@ -282,7 +282,7 @@ def run_network_checks(env, output):
# The user might have ended up on an IP address that was previously in use
# by a spammer, or the user may be deploying on a residential network. We
# will not be able to reliably send mail in these cases.
-
+
# See https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now. for
# information on spamhaus return codes
rev_ip4 = ".".join(reversed(env['PUBLIC_IP'].split('.')))
@@ -721,7 +721,7 @@ def check_mail_domain(domain, env, output):
# Stop if the domain is listed in the Spamhaus Domain Block List.
# The user might have chosen a domain that was previously in use by a spammer
# and will not be able to reliably send mail.
-
+
# See https://www.spamhaus.org/news/article/807/using-our-public-mirrors-check-your-return-codes-now. for
# information on spamhaus return codes
dbl = query_dns(domain+'.dbl.spamhaus.org', "A", nxdomain=None)
diff --git a/management/templates/users.html b/management/templates/users.html
index 4924c7c8..da04fcc9 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
@@ -27,6 +28,10 @@
+
+
+
+
@@ -34,13 +39,18 @@
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)