1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2025-04-21 03:02:09 +00:00

Implemented quota.

- Implemented quota to management.
- Quota to have 0 as default, added more description.
This commit is contained in:
Rainulf Pineda 2016-01-05 16:05:45 -05:00
parent cb162da5fe
commit 590321fcb7
7 changed files with 138 additions and 16 deletions

View File

@ -6,7 +6,7 @@ from functools import wraps
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
import auth, utils, multiprocessing.pool 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_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_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias
@ -154,7 +154,11 @@ def mail_users():
@authorized_personnel_only @authorized_personnel_only
def mail_users_add(): def mail_users_add():
try: 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: except ValueError as e:
return (str(e), 400) return (str(e), 400)
@ -166,6 +170,14 @@ def mail_users_password():
except ValueError as e: except ValueError as e:
return (str(e), 400) 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']) @app.route('/mail/users/remove', methods=['POST'])
@authorized_personnel_only @authorized_personnel_only
def mail_users_remove(): def mail_users_remove():

View File

@ -128,14 +128,15 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
users = [] users = []
active_accounts = set() active_accounts = set()
c = open_database(env) c = open_database(env)
c.execute('SELECT email, privileges FROM users') c.execute('SELECT email, privileges, quota FROM users')
for email, privileges in c.fetchall(): for email, privileges, quota in c.fetchall():
active_accounts.add(email) active_accounts.add(email)
user = { user = {
"email": email, "email": email,
"privileges": parse_privs(privileges), "privileges": parse_privs(privileges),
"status": "active", "status": "active",
"quota": quota,
} }
users.append(user) users.append(user)
@ -156,6 +157,7 @@ def get_mail_users_ex(env, with_archived=False, with_slow_info=False):
"privileges": "", "privileges": "",
"status": "inactive", "status": "inactive",
"mailbox": mbox, "mailbox": mbox,
"quota": "?",
} }
users.append(user) users.append(user)
if with_slow_info: 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) ] + [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 # validate email
if email.strip() == "": if email.strip() == "":
return ("No email address provided.", 400) 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. # 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) 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
validate_password(pw) validate_password(pw)
# validate quota
validate_quota(quota)
# validate privileges # validate privileges
if privs is None or privs.strip() == "": 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 # add the user to the database
try: try:
c.execute("INSERT INTO users (email, password, privileges) VALUES (?, ?, ?)", c.execute("INSERT INTO users (email, password, quota, privileges) VALUES (?, ?, ?, ?)",
(email, pw, "\n".join(privs))) (email, pw, quota, "\n".join(privs)))
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
return ("User already exists.", 400) return ("User already exists.", 400)
@ -331,6 +338,20 @@ def set_mail_password(email, pw, env):
conn.commit() conn.commit()
return "OK" 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): def hash_password(pw):
# Turn the plain password into a Dovecot-format hashed password, meaning # Turn the plain password into a Dovecot-format hashed password, meaning
# something like "{SCHEME}hashedpassworddata". # something like "{SCHEME}hashedpassworddata".
@ -613,6 +634,13 @@ def validate_password(pw):
if len(pw) < 8: if len(pw) < 8:
raise ValueError("Passwords must be at least eight characters.") 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__": if __name__ == "__main__":
import sys import sys

View File

@ -22,6 +22,10 @@
<label class="sr-only" for="adduserPassword">Password</label> <label class="sr-only" for="adduserPassword">Password</label>
<input type="password" class="form-control" id="adduserPassword" placeholder="Password"> <input type="password" class="form-control" id="adduserPassword" placeholder="Password">
</div> </div>
<div class="form-group">
<label class="sr-only" for="adduserQuota">Quota (MB)</label>
<input type="text" class="form-control" id="adduserQuota" placeholder="Quota (MB)">
</div>
<div class="form-group"> <div class="form-group">
<select class="form-control" id="adduserPrivs"> <select class="form-control" id="adduserPrivs">
<option value="">Normal User</option> <option value="">Normal User</option>
@ -31,6 +35,7 @@
<button type="submit" class="btn btn-primary">Add User</button> <button type="submit" class="btn btn-primary">Add User</button>
</form> </form>
<ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;"> <ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
<li>Enter blank or 0 for no quota (unlimited) accounts.</li>
<li>Passwords must be at least eight characters and may not contain spaces. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li> <li>Passwords must be at least eight characters and may not contain spaces. For best results, <a href="#" onclick="return generate_random_password()">generate a random password</a>.</li>
<li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li> <li>Use <a href="#" onclick="return show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
<li>Administrators get access to this control panel.</li> <li>Administrators get access to this control panel.</li>
@ -44,6 +49,7 @@
<th width="50%">Email Address</th> <th width="50%">Email Address</th>
<th>Actions</th> <th>Actions</th>
<th>Mailbox Size</th> <th>Mailbox Size</th>
<th>Mailbox Quota</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -66,6 +72,13 @@
| |
</span> </span>
<span class="if_active">
<a href="#" onclick="users_set_quota(this); return false;" class='setquota' title="Set Quota">
set quota
</a>
|
</span>
<span class='add-privs'> <span class='add-privs'>
</span> </span>
@ -75,6 +88,8 @@
</td> </td>
<td class='mailboxsize'> <td class='mailboxsize'>
</td> </td>
<td class='mailboxquota'>
</td>
</tr> </tr>
<tr id="user-extra-template" class="if_inactive"> <tr id="user-extra-template" class="if_inactive">
<td colspan="3" style="border: 0; padding-top: 0"> <td colspan="3" style="border: 0; padding-top: 0">
@ -157,6 +172,7 @@ function show_users() {
n.attr('data-email', user.email); n.attr('data-email', user.email);
n.find('.address').text(user.email) n.find('.address').text(user.email)
n.find('.mailboxsize').text(nice_size(user.mailbox_size)) n.find('.mailboxsize').text(nice_size(user.mailbox_size))
n.find('.mailboxquota').text(''+user.quota + " MB")
n2.find('.restore_info tt').text(user.mailbox); n2.find('.restore_info tt').text(user.mailbox);
if (user.status == 'inactive') continue; if (user.status == 'inactive') continue;
@ -185,13 +201,15 @@ function do_add_user() {
var email = $("#adduserEmail").val(); var email = $("#adduserEmail").val();
var pw = $("#adduserPassword").val(); var pw = $("#adduserPassword").val();
var privs = $("#adduserPrivs").val(); var privs = $("#adduserPrivs").val();
var quota = $("#adduserQuota").val();
api( api(
"/mail/users/add", "/mail/users/add",
"POST", "POST",
{ {
email: email, email: email,
password: pw, password: pw,
privileges: privs privileges: privs,
quota: quota
}, },
function(r) { function(r) {
// Responses are multiple lines of pre-formatted text. // Responses are multiple lines of pre-formatted text.
@ -233,6 +251,33 @@ function users_set_password(elem) {
}); });
} }
function users_set_quota(elem) {
var email = $(elem).parents('tr').attr('data-email');
var quota = "";
show_modal_confirm(
"User Quota",
$("<p>Set a new quota for <b>" + email + "</b>?</p> <p><label for='quota_value' style='display: block; font-weight: normal'>New Quota (MB):</label><input type='text' id='quota_value'></p><p><small>Quota must be positive integer. Enter 0 or blank for no quota.</small></p>"),
"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", $("<pre/>").text(r));
},
function(r) {
show_modal_error("Set Quota", r);
});
});
}
function users_remove(elem) { function users_remove(elem) {
var email = $(elem).parents('tr').attr('data-email'); var email = $(elem).parents('tr').attr('data-email');

View File

@ -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 # 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) # 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/). # 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 \ # Also added imap_quota for quota display on roundcube.
imap_idle_notify_interval="4 mins" 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. # Set POP3 UIDL.
# UIDLs are used by POP3 clients to keep track of what messages they've downloaded. # 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, # Full Text Search - Enable full text search of mail using dovecot's lucene plugin,
# which *we* package and distribute (dovecot-lucene package). # which *we* package and distribute (dovecot-lucene package).
tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \ 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; cat > /etc/dovecot/conf.d/90-plugin-fts.conf << EOF;
plugin { plugin {
fts = lucene fts = lucene

View File

@ -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 # "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 \ 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_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 # 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). # Postgrey listens on the same interface (and not IPv6, for instance).

View File

@ -20,7 +20,7 @@ db_path=$STORAGE_ROOT/mail/users.sqlite
# Create an empty database if it doesn't yet exist. # Create an empty database if it doesn't yet exist.
if [ ! -f $db_path ]; then if [ ! -f $db_path ]; then
echo Creating new user database: $db_path; 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; 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 fi
@ -40,6 +40,7 @@ passdb {
userdb { userdb {
driver = sql driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext args = /etc/dovecot/dovecot-sql.conf.ext
default_fields = uid=mail gid=mail home=$STORAGE_ROOT/mail/mailboxes/%d/%n
} }
EOF EOF
@ -48,6 +49,7 @@ cat > /etc/dovecot/dovecot-sql.conf.ext << EOF;
driver = sqlite driver = sqlite
connect = $db_path connect = $db_path
default_pass_scheme = SHA512-CRYPT 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'; 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 FROM users WHERE email='%u';
iterate_query = SELECT email AS user FROM users; iterate_query = SELECT email AS user FROM users;
@ -148,5 +150,3 @@ EOF
restart_service postfix restart_service postfix
restart_service dovecot restart_service dovecot

View File

@ -148,6 +148,12 @@ def migration_11(env):
# meh # meh
pass 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(): def get_current_migration():
ver = 0 ver = 0
while True: while True:
@ -225,4 +231,3 @@ if __name__ == "__main__":
elif sys.argv[-1] == "--migrate": elif sys.argv[-1] == "--migrate":
# Perform migrations. # Perform migrations.
run_migrations() run_migrations()