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 6 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
63 changes: 63 additions & 0 deletions conf/dovecot/conf.d/15-mailboxes.conf
Copy link
Member

Choose a reason for hiding this comment

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

I think this file is mistakenly added. I think it duplicates a file two directories up. I didn't check if the files are different.

Original file line number Diff line number Diff line change
@@ -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
}
}
20 changes: 20 additions & 0 deletions management/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -88,6 +90,10 @@ def setup_key_auth(mgmt_uri):
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"}:
Expand Down Expand Up @@ -117,6 +123,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:
# Set a user's quota
Copy link

Choose a reason for hiding this comment

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

Is this comment correct? I don't understand how this sets a user's quota.

Copy link
Member

Choose a reason for hiding this comment

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

Looks like this is going to display not set a users current 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 @@ -138,6 +152,12 @@ def setup_key_auth(mgmt_uri):
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)
Expand Down
49 changes: 48 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, get_default_quota, validate_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', 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)

Expand Down Expand Up @@ -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/')
Expand Down
124 changes: 119 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,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,
Copy link
Member

Choose a reason for hiding this comment

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

I guess this is the number of messages. Since we don't enforce a limit for this, it's a little confusing to report. I'd suggest removing it.

"status": "active",
}
users.append(user)
Expand All @@ -150,6 +197,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)

Expand Down Expand Up @@ -266,7 +317,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 +343,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)

Expand All @@ -300,14 +359,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 +393,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.
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