Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated Quota Support to Latest Version of MiaB and resolving code review comments #2387

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion management/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def setup_key_auth(mgmt_uri):
{cli} user password [email protected] [password]
{cli} user remove [email protected]
{cli} user make-admin [email protected]
{cli} user quota user@domain [new-quota] (get or set user quota)
{cli} user remove-admin [email protected]
{cli} user admins (lists admins)
{cli} user mfa show [email protected] (shows MFA devices for user, if any)
Expand Down Expand Up @@ -117,6 +118,14 @@ def setup_key_auth(mgmt_uri):
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] })

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)
Expand All @@ -141,4 +150,3 @@ def setup_key_auth(mgmt_uri):
else:
print("Invalid command-line arguments.")
sys.exit(1)

26 changes: 25 additions & 1 deletion management/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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
from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
import contextlib

Expand Down Expand Up @@ -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', '0')
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)

Expand Down
115 changes: 110 additions & 5 deletions management/mailconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
# address entered by the user.

import os, sqlite3, re
import subprocess

import utils
from email_validator import validate_email as validate_email_, EmailNotValidError
import idna
Expand Down Expand Up @@ -102,6 +104,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.
Expand All @@ -125,13 +139,42 @@ 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_quota = 0
percent = ''
try:
dirsize_file = os.path.join(env['STORAGE_ROOT'], 'mail/mailboxes/%s/%s/maildirsize' % (domain, user))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of reading the "maildirsize" file directly, which is specific to the maildir quota backend, why not execute doveadm -f json quota get -u <email> and parse the json, which contains both quota ("limit") in KiB, current size ("value") also in KiB, and percent used ("percent"). The "STORAGE" type is what you want. "MESSAGE" is a count of messages. The json looks like this:

[{"root":"User quota","type":"STORAGE","value":"2","limit":"5120","percent":"0"},{"root":"User quota","type":"MESSAGE","value":"1","limit":"-","percent":"0"}]```

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": 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,
"status": "active",
}
users.append(user)
Expand All @@ -150,6 +193,9 @@ def get_mail_users_ex(env, with_archived=False):
"privileges": [],
"status": "inactive",
"mailbox": mbox,
"box_size": '?',
"box_quota": '?',
"percent": '?',
}
users.append(user)

Expand Down Expand Up @@ -266,7 +312,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)
Expand All @@ -292,6 +338,14 @@ def add_mail_user(email, pw, privs, 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, c = open_database(env, with_connection=True)

Expand All @@ -300,14 +354,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")

Expand All @@ -332,6 +388,55 @@ 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 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.
Expand Down
4 changes: 2 additions & 2 deletions management/status_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('.')))
Expand Down Expand Up @@ -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)
Expand Down
Loading