diff --git a/conf/dovecot-mailboxes.conf b/conf/dovecot/conf.d/15-mailboxes.conf similarity index 100% rename from conf/dovecot-mailboxes.conf rename to conf/dovecot/conf.d/15-mailboxes.conf diff --git a/conf/dovecot/conf.d/20-imap.conf b/conf/dovecot/conf.d/20-imap.conf new file mode 100644 index 0000000..b9855b8 --- /dev/null +++ b/conf/dovecot/conf.d/20-imap.conf @@ -0,0 +1,94 @@ +## +## IMAP specific settings +## + +# If nothing happens for this long while client is IDLEing, move the connection +# to imap-hibernate process and close the old imap process. This saves memory, +# because connections use very little memory in imap-hibernate process. The +# downside is that recreating the imap process back uses some resources. +#imap_hibernate_timeout = 0 + +# Maximum IMAP command line length. Some clients generate very long command +# lines with huge mailboxes, so you may need to raise this if you get +# "Too long argument" or "IMAP command line too large" errors often. +#imap_max_line_length = 64k + +# IMAP logout format string: +# %i - total number of bytes read from client +# %o - total number of bytes sent to client +# %{fetch_hdr_count} - Number of mails with mail header data sent to client +# %{fetch_hdr_bytes} - Number of bytes with mail header data sent to client +# %{fetch_body_count} - Number of mails with mail body data sent to client +# %{fetch_body_bytes} - Number of bytes with mail body data sent to client +# %{deleted} - Number of mails where client added \Deleted flag +# %{expunged} - Number of mails that client expunged, which does not +# include automatically expunged mails +# %{autoexpunged} - Number of mails that were automatically expunged after +# client disconnected +# %{trashed} - Number of mails that client copied/moved to the +# special_use=\Trash mailbox. +# %{appended} - Number of mails saved during the session +#imap_logout_format = in=%i out=%o + +# Override the IMAP CAPABILITY response. If the value begins with '+', +# add the given capabilities on top of the defaults (e.g. +XFOO XBAR). +#imap_capability = + +# How long to wait between "OK Still here" notifications when client is +# IDLEing. +#imap_idle_notify_interval = 2 mins +imap_idle_notify_interval=4 mins + +# ID field names and values to send to clients. Using * as the value makes +# Dovecot use the default value. The following fields have default values +# currently: name, version, os, os-version, support-url, support-email. +#imap_id_send = + +# ID fields sent by client to log. * means everything. +#imap_id_log = + +# Workarounds for various client bugs: +# delay-newmail: +# Send EXISTS/RECENT new mail notifications only when replying to NOOP +# and CHECK commands. Some clients ignore them otherwise, for example OSX +# Mail ( + +## +## Quota limits +## + +# Quota limits are set using "quota_rule" parameters. To get per-user quota +# limits, you can set/override them by returning "quota_rule" extra field +# from userdb. It's also possible to give mailbox-specific limits, for example +# to give additional 100 MB when saving to Trash: + +plugin { + #quota_rule = *:storage=1G + #quota_rule2 = Trash:storage=+100M + + # LDA/LMTP allows saving the last mail to bring user from under quota to + # over quota, if the quota doesn't grow too high. Default is to allow as + # long as quota will stay under 10% above the limit. Also allowed e.g. 10M. + #quota_grace = 10%% + + # Quota plugin can also limit the maximum accepted mail size. + #quota_max_mail_size = 100M +} + +## +## Quota warnings +## + +# You can execute a given command when user exceeds a specified quota limit. +# Each quota root has separate limits. Only the command for the first +# exceeded limit is excecuted, so put the highest limit first. +# The commands are executed via script service by connecting to the named +# UNIX socket (quota-warning below). +# Note that % needs to be escaped as %%, otherwise "% " expands to empty. + +plugin { + #quota_warning = storage=95%% quota-warning 95 %u + #quota_warning2 = storage=80%% quota-warning 80 %u +} + +# Example quota-warning service. The unix listener's permissions should be +# set in a way that mail processes can connect to it. Below example assumes +# that mail processes run as vmail user. If you use mode=0666, all system users +# can generate quota warnings to anyone. +#service quota-warning { +# executable = script /usr/local/bin/quota-warning.sh +# user = dovecot +# unix_listener quota-warning { +# user = vmail +# } +#} + +## +## Quota backends +## + +# Multiple backends are supported: +# dirsize: Find and sum all the files found from mail directory. +# Extremely SLOW with Maildir. It'll eat your CPU and disk I/O. +# dict: Keep quota stored in dictionary (eg. SQL) +# maildir: Maildir++ quota +# fs: Read-only support for filesystem quota + +plugin { + quota = maildir + + quota_grace = 10% + + quota_status_success = DUNNO + quota_status_nouser = DUNNO + quota_status_overquota = "522 5.2.2 Mailbox is full" + + #quota = dirsize:User quota + #quota = maildir:User quota + #quota = dict:User quota::proxy::quota + #quota = fs:User quota +} + +service quota-status { + executable = quota-status -p postfix + inet_listener { + port = 12340 + } +} + +# Multiple quota roots are also possible, for example this gives each user +# their own 100MB quota and one shared 1GB quota within the domain: +plugin { + #quota = dict:user::proxy::quota + #quota2 = dict:domain:%d:proxy::quota_domain + #quota_rule = *:storage=102400 + #quota2_rule = *:storage=1048576 +} diff --git a/management/cli.py b/management/cli.py index 1b91b00..9d4e89f 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"): @@ -123,6 +129,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) @@ -144,6 +158,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 e6f5525..5870cdd 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -18,6 +18,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, open_database 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 env = utils.load_environment() @@ -177,8 +178,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) @@ -757,6 +781,28 @@ def update_wkd(): from wkd import update_wkd_config, build_wkd update_wkd_config(request.form) build_wkd() + +@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 diff --git a/management/mailconfig.py b/management/mailconfig.py index af73bfe..cf55681 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -105,6 +105,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. @@ -128,13 +140,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) @@ -153,6 +198,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) @@ -289,7 +338,7 @@ def get_mail_domains(env, filter_aliases=lambda alias : True, users_only=False): domains.extend([get_domain(address, as_unicode=False) for address in get_noreply_addresses(env)]) 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) @@ -315,6 +364,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) @@ -323,14 +380,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") @@ -355,6 +414,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/templates/users.html b/management/templates/users.html index 4b34987..3a1f752 100644 --- a/management/templates/users.html +++ b/management/templates/users.html @@ -7,6 +7,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

@@ -28,6 +29,10 @@ +
+ + +

Existing mail users

- + + + + + @@ -54,10 +64,22 @@ + + + +
Email AddressEmail AddressMessagesSizeUsedQuota Actions
+ + + + set quota + + | + + set password @@ -98,10 +120,28 @@ - - + + + + + + + + + + + + + + + + + + + +
Verb Action
GET(none) Returns a list of existing mail users. Adding ?format=json to the URL will give JSON-encoded results.
POST/add Adds a new mail user. Required POST-body parameters are email and password.
POST/remove Removes a mail user. Required POST-body parameter is email.
POST/addAdds a new mail user. Required POST-body parameters are email and password. Optional parameters: privilege=admin and quota
POST/removeRemoves a mail user. Required POST-by parameter is email.
POST/privileges/add Used to make a mail user an admin. Required POST-body parameters are email and privilege=admin.
POST/privileges/remove Used to remove the admin privilege from a mail user. Required POST-body parameter is email.
GET/quotaGet the quota for a mail user. Required POST-body parameters are email and will return JSON result
POST/quotaSet the quota for a mail user. Required POST-body parameters are email and quota.

Examples:

@@ -126,6 +166,15 @@ curl -X POST -d "email=new_user@mydomail.com" https://{{hostname}}/admin/mail/us diff --git a/setup/mail-dovecot.sh b/setup/mail-dovecot.sh index 87486a8..121b597 100755 --- a/setup/mail-dovecot.sh +++ b/setup/mail-dovecot.sh @@ -66,7 +66,9 @@ management/editconf.py /etc/dovecot/conf.d/10-mail.conf \ first_valid_uid=0 # 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 +cp conf/dovecot/conf.d/15-mailboxes.conf /etc/dovecot/conf.d/ +cp conf/dovecot/conf.d/20-imap.conf /etc/dovecot/conf.d/ +cp conf/dovecot/conf.d/90-quota.conf /etc/dovecot/conf.d/ # ### IMAP/POP diff --git a/setup/mail-postfix.sh b/setup/mail-postfix.sh index 699b4c1..7f2c9e4 100755 --- a/setup/mail-postfix.sh +++ b/setup/mail-postfix.sh @@ -216,7 +216,7 @@ management/editconf.py /etc/postfix/main.cf lmtp_destination_recipient_limit=1 # "450 4.7.1 Client host rejected: Service unavailable". This is a retry code, so the mail doesn't properly bounce. #NODOC management/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" \ - smtpd_recipient_restrictions="check_recipient_access sqlite:/etc/postfix/noreply-addresses.cf",permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"check_policy_service inet:127.0.0.1:10023" + smtpd_recipient_restrictions="check_recipient_access sqlite:/etc/postfix/noreply-addresses.cf",permit_sasl_authenticated,permit_mynetworks,"reject_rbl_client zen.spamhaus.org",reject_unlisted_recipient,"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). diff --git a/setup/mail-users.sh b/setup/mail-users.sh index 34092b7..05ab1b3 100755 --- a/setup/mail-users.sh +++ b/setup/mail-users.sh @@ -20,10 +20,12 @@ db_path=$STORAGE_ROOT/mail/users.sqlite # Create an empty database if it doesn't yet exist. if [ ! -f $db_path ]; then echo Creating new user database: $db_path; - echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path; + echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '', quota TEXT NOT NULL DEFAULT '0');" | sqlite3 $db_path; echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path; echo "CREATE TABLE noreply (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE);" | sqlite3 $db_path echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path; +elif sqlite3 $db_path ".schema users" | grep --invert-match quota; then + echo "ALTER TABLE users ADD COLUMN quota TEXT NOT NULL DEFAULT '0';" | sqlite3 $db_path; fi # ### User Authentication @@ -51,7 +53,7 @@ driver = sqlite connect = $db_path default_pass_scheme = SHA512-CRYPT password_query = SELECT email as user, password FROM users WHERE email='%u'; -user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORAGE_ROOT/mail/mailboxes/%d/%n" as home FROM users WHERE email='%u'; +user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORAGE_ROOT/mail/mailboxes/%d/%n" as home, '*:bytes=' || quota AS quota_rule FROM users WHERE email='%u'; iterate_query = SELECT email AS user FROM users; EOF chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions @@ -161,4 +163,5 @@ EOF restart_service postfix restart_service dovecot - +# force a recalculation of all user quotas +doveadm quota recalc -A diff --git a/setup/webmail.sh b/setup/webmail.sh index fa0b1db..08c985a 100755 --- a/setup/webmail.sh +++ b/setup/webmail.sh @@ -131,7 +131,7 @@ cat > $RCM_CONFIG < +\$config['quota_zero_as_unlimited'] = true; EOF mkdir -p ${STORAGE_ROOT}/.enigma/