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 00000000..b9855b89 --- /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/daemon.py b/management/daemon.py index b7bf2a66..00f756e9 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -9,7 +9,7 @@ import auth, utils, multiprocessing.pool 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 env = utils.load_environment() auth_service = auth.KeyAuthService() @@ -153,8 +153,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) @@ -519,6 +542,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 b061ea7d..c035442e 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) @@ -268,7 +313,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_mail_aliases(env) if filter_aliases(address) ]) 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) @@ -294,6 +339,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) @@ -302,14 +355,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") @@ -334,6 +389,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 36da034a..58b36c88 100755 --- a/management/status_checks.py +++ b/management/status_checks.py @@ -28,6 +28,7 @@ def get_services(): { "name": "Dovecot LMTP LDA", "port": 10026, "public": False, }, { "name": "Postgrey", "port": 10023, "public": False, }, { "name": "Spamassassin", "port": 10025, "public": False, }, + { "name": "IMAP Quota", "port": 12340, "public": False }, { "name": "OpenDKIM", "port": 8891, "public": False, }, { "name": "OpenDMARC", "port": 8893, "public": False, }, { "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, }, @@ -840,7 +841,7 @@ def get_latest_miab_version(): from socket import timeout try: - return re.search(b'TAG=(.*)', urlopen("https://mailinabox.email/setup.sh?ping=1", timeout=5).read()).group(1).decode("utf8") + return re.search(b'TAG=(.*)', urlopen("https://raw.githubusercontent.com/jrsupplee/mailinabox/master/setup/bootstrap.sh", timeout=5).read()).group(1).decode("utf8") except (HTTPError, URLError, timeout): return None @@ -858,11 +859,11 @@ def check_miab_version(env, output): latest_ver = get_latest_miab_version() if this_ver == latest_ver: - output.print_ok("Mail-in-a-Box is up to date. You are running version %s." % this_ver) + output.print_ok("Mail-in-a-Box with quota support is up to date. You are running version %s." % this_ver) elif latest_ver is None: output.print_error("Latest Mail-in-a-Box version could not be determined. You are running version %s." % this_ver) else: - output.print_error("A new version of Mail-in-a-Box is available. You are running version %s. The latest version is %s. For upgrade instructions, see https://mailinabox.email. " + output.print_error("A new version of Mail-in-a-Box is available. You are running version %s. The latest version is %s. For upgrade instructions, see https://github.com/jrsupplee/mailinabox/blob/master/README.md. " % (this_ver, latest_ver)) def run_and_output_changes(env, pool): diff --git a/management/templates/users.html b/management/templates/users.html index 78fef61a..b808263f 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,19 @@ + + + +
Email AddressEmail AddressMessagesSizeUsedQuota Actions
+ + + set quota + + | set password @@ -126,6 +145,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 c6e6cb3a..44ac3ea3 100755 --- a/setup/mail-dovecot.sh +++ b/setup/mail-dovecot.sh @@ -66,7 +66,9 @@ tools/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 0a66cb0f..c10c18b7 100755 --- a/setup/mail-postfix.sh +++ b/setup/mail-postfix.sh @@ -216,7 +216,7 @@ tools/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 tools/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=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=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 e54485bb..8c7be06a 100755 --- a/setup/mail-users.sh +++ b/setup/mail-users.sh @@ -20,8 +20,10 @@ 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; +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 @@ -49,7 +51,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 @@ -153,4 +155,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 1e7d0083..7172e019 100755 --- a/setup/webmail.sh +++ b/setup/webmail.sh @@ -131,7 +131,7 @@ cat > $RCM_CONFIG < +\$config['quota_zero_as_unlimited'] = true; EOF # Configure CardDav diff --git a/tools/mail.py b/tools/mail.py index 215f39eb..c7080eb5 100755 --- a/tools/mail.py +++ b/tools/mail.py @@ -54,9 +54,11 @@ def setup_key_auth(mgmt_uri): if len(sys.argv) < 2: print("Usage: ") - print(" tools/mail.py user (lists users)") + print(" tools/mail.py system default-quota [new default]") + print(" tools/mail.py user (lists users with quotas)") print(" tools/mail.py user add user@domain.com [password]") print(" tools/mail.py user password user@domain.com [password]") + print(" tools/mail.py user quota user@domain [new-quota]") print(" tools/mail.py user remove user@domain.com") print(" tools/mail.py user make-admin user@domain.com") print(" tools/mail.py user remove-admin user@domain.com") @@ -78,6 +80,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"): @@ -113,6 +119,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] == "alias" and len(sys.argv) == 2: print(mgmt("/mail/aliases")) @@ -122,6 +136,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)