diff --git a/management/daemon.py b/management/daemon.py index 2e23c8aa..c956bb9a 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -6,7 +6,7 @@ from functools import wraps from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response 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_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, set_mail_quota, 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 @@ -154,7 +154,11 @@ def mail_users(): @authorized_personnel_only def mail_users_add(): 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', ''), + request.form.get('quota', ''), env) except ValueError as e: return (str(e), 400) @@ -166,6 +170,14 @@ def mail_users_password(): except ValueError as e: return (str(e), 400) +@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) + @app.route('/mail/users/remove', methods=['POST']) @authorized_personnel_only def mail_users_remove(): diff --git a/management/mailconfig.py b/management/mailconfig.py index 82c922e4..04baf296 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -128,14 +128,15 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=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 = { "email": email, "privileges": parse_privs(privileges), "status": "active", + "quota": quota, } users.append(user) @@ -156,6 +157,7 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False): "privileges": "", "status": "inactive", "mailbox": mbox, + "quota": "?", } users.append(user) if with_slow_info: @@ -271,7 +273,7 @@ def get_mail_domains(env, filter_aliases=lambda alias : True): + [get_domain(address, as_unicode=False) for address, *_ in get_mail_aliases(env) if filter_aliases(address) ] ) -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) @@ -285,8 +287,13 @@ def add_mail_user(email, pw, privs, env): # during box setup the user won't know the rules. return ("You may not make a user account for that address because it is frequently used for domain control validation. Use an alias instead if necessary.", 400) + if not quota: + quota = 0 + # validate password validate_password(pw) + # validate quota + validate_quota(quota) # validate privileges if privs is None or privs.strip() == "": @@ -305,8 +312,8 @@ 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, quota, privileges) VALUES (?, ?, ?, ?)", + (email, pw, quota, "\n".join(privs))) except sqlite3.IntegrityError: return ("User already exists.", 400) @@ -331,6 +338,20 @@ def set_mail_password(email, pw, env): conn.commit() return "OK" +def set_mail_quota(email, quota, env): + if not quota: + quota = 0 + # validate 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() + return "OK" + def hash_password(pw): # Turn the plain password into a Dovecot-format hashed password, meaning # something like "{SCHEME}hashedpassworddata". @@ -613,6 +634,13 @@ def validate_password(pw): if len(pw) < 8: raise ValueError("Passwords must be at least eight characters.") +def validate_quota(quota): + # validate quota + quota = str(quota) + if not quota.isdigit(): + raise ValueError("Quota must be a number.") + if int(quota) < 0: + raise ValueError("Quota must be a positive number.") if __name__ == "__main__": import sys diff --git a/management/templates/users.html b/management/templates/users.html index cf944c86..2e40f104 100644 --- a/management/templates/users.html +++ b/management/templates/users.html @@ -22,6 +22,10 @@ +
+ + +

Quota must be positive integer. Enter 0 or blank for no quota.

"), + "Set Quota", + function() { + api( + "/mail/users/quota", + "POST", + { + email: email, + quota: $('#quota_value').val() + }, + function(r) { + // Responses are multiple lines of pre-formatted text. + show_modal_error("Set Quota", $("
").text(r));
+        },
+        function(r) {
+          show_modal_error("Set Quota", r);
+        });
+    });
+}
+
 function users_remove(elem) {
   var email = $(elem).parents('tr').attr('data-email');
 
diff --git a/setup/mail-dovecot.sh b/setup/mail-dovecot.sh
index 21343964..9c914bb0 100755
--- a/setup/mail-dovecot.sh
+++ b/setup/mail-dovecot.sh
@@ -102,8 +102,13 @@ sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf
 # The risk is that if the connection is silent for too long it might be reset
 # by a peer. See [#129](https://github.com/mail-in-a-box/mailinabox/issues/129)
 # and [How bad is IMAP IDLE](http://razor.occams.info/blog/2014/08/09/how-bad-is-imap-idle/).
-tools/editconf.py /etc/dovecot/conf.d/20-imap.conf \
-	imap_idle_notify_interval="4 mins"
+# Also added imap_quota for quota display on roundcube.
+cat > /etc/dovecot/conf.d/20-imap.conf << EOF;
+imap_idle_notify_interval=4 mins
+protocol imap {
+  mail_plugins = \$mail_plugins antispam imap_quota
+}
+EOF
 
 # Set POP3 UIDL.
 # UIDLs are used by POP3 clients to keep track of what messages they've downloaded.
@@ -115,7 +120,34 @@ tools/editconf.py /etc/dovecot/conf.d/20-pop3.conf \
 # Full Text Search - Enable full text search of mail using dovecot's lucene plugin,
 # which *we* package and distribute (dovecot-lucene package).
 tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
-	mail_plugins="\$mail_plugins fts fts_lucene"
+	mail_plugins="\$mail_plugins fts fts_lucene quota"
+
+# Configure a simple usage of quota.
+# See this: http://wiki2.dovecot.org/Quota/Configuration
+#
+# dovecot quota as policy-service for postfix instead of dovecot to bounce it
+# See this: https://sys4.de/en/blog/2013/04/08/postfix-dovecot-mailbox-quota/
+cat > /etc/dovecot/conf.d/90-quota.conf << EOF;
+plugin {
+  quota = maildir:User quota
+  #quota_rule = *:storage=1M
+  #quota_rule2 = Trash:storage=+100M
+
+  quota_grace = 10%%
+  quota_status_success = DUNNO
+  quota_status_nouser = DUNNO
+  quota_status_overquota = "552 5.2.2 Mailbox is full"
+}
+
+service quota-status {
+  executable = quota-status -p postfix
+  inet_listener {
+    port = 12340
+  }
+  client_limit = 1
+}
+EOF
+
 cat > /etc/dovecot/conf.d/90-plugin-fts.conf << EOF;
 plugin {
   fts = lucene
diff --git a/setup/mail-postfix.sh b/setup/mail-postfix.sh
index ca52edbd..bb533347 100755
--- a/setup/mail-postfix.sh
+++ b/setup/mail-postfix.sh
@@ -200,7 +200,7 @@ tools/editconf.py /etc/postfix/main.cf virtual_transport=lmtp:[127.0.0.1]:10025
 # "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 ef9b8118..de19cd6c 100755
--- a/setup/mail-users.sh
+++ b/setup/mail-users.sh
@@ -20,7 +20,7 @@ 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, quota NUMERIC NOT NULL DEFAULT 0, extra, privileges TEXT NOT NULL DEFAULT '');" | 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;
 fi
 
@@ -40,6 +40,7 @@ passdb {
 userdb {
   driver = sql
   args = /etc/dovecot/dovecot-sql.conf.ext
+  default_fields = uid=mail gid=mail home=$STORAGE_ROOT/mail/mailboxes/%d/%n
 }
 EOF
 
@@ -48,6 +49,7 @@ cat > /etc/dovecot/dovecot-sql.conf.ext << EOF;
 driver = sqlite
 connect = $db_path
 default_pass_scheme = SHA512-CRYPT
+user_query = SELECT '*:storage=' || quota || 'M' AS quota_rule FROM users WHERE email='%u';
 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';
 iterate_query = SELECT email AS user FROM users;
@@ -148,5 +150,3 @@ EOF
 
 restart_service postfix
 restart_service dovecot
-
-
diff --git a/setup/migrate.py b/setup/migrate.py
index 1d5911ab..c3a6948f 100755
--- a/setup/migrate.py
+++ b/setup/migrate.py
@@ -148,6 +148,12 @@ def migration_11(env):
 		# meh
 		pass
 
+def migration_12(env):
+	# Add quota column to `users` table for quota implementation per user.
+	# Note that the numeric value represents megabyte. 0 is unlimited.
+	db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
+	shell("check_call", ["sqlite3", db, "ALTER TABLE users ADD quota NUMERIC NOT NULL DEFAULT 0"])
+
 def get_current_migration():
 	ver = 0
 	while True:
@@ -225,4 +231,3 @@ if __name__ == "__main__":
 	elif sys.argv[-1] == "--migrate":
 		# Perform migrations.
 		run_migrations()
-