diff --git a/.github/workflows/commit-tests.yml b/.github/workflows/commit-tests.yml
index 98649937..a5f6df62 100644
--- a/.github/workflows/commit-tests.yml
+++ b/.github/workflows/commit-tests.yml
@@ -33,8 +33,7 @@ jobs:
runs-on: ubuntu-22.04
env:
PRIMARY_HOSTNAME: box2.abc.com
- # TODO: change UPSTREAM_TAG to 'main' once upstream is installable
- UPSTREAM_TAG: v67
+ UPSTREAM_TAG: main
steps:
- uses: actions/checkout@v4
- name: setup
diff --git a/conf/schema/postfix.schema b/conf/schema/postfix.schema
index 8056998e..d04273f4 100644
--- a/conf/schema/postfix.schema
+++ b/conf/schema/postfix.schema
@@ -18,12 +18,12 @@ objectIdentifier MiabLDAPmail MiabLDAProot:2
objectIdentifier MiabLDAPmailAttributeType MiabLDAPmail:1
objectIdentifier MiabLDAPmailObjectClass MiabLDAPmail:2
-attributetype ( 1.3.6.1.4.1.15347.2.102
- NAME 'transport'
+attributetype ( 1.3.6.1.4.1.15347.2.102
+ NAME 'transport'
SUP name)
-attributetype ( 1.3.6.1.4.1.15347.2.101
- NAME 'mailRoutingAddress'
+attributetype ( 1.3.6.1.4.1.15347.2.101
+ NAME 'mailRoutingAddress'
SUP mail )
attributetype ( 1.3.6.1.4.1.15347.2.110 NAME 'maildest'
@@ -56,13 +56,74 @@ attributetype ( MiabLDAPmailAttributeType:1 NAME 'mailMember' DESC 'RFC6532 utf8
# create a utf8 version of core 'domainComponent'
attributetype ( MiabLDAPmailAttributeType:2 NAME 'dcIntl' DESC 'UTF8 domain component' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE )
+# Create mda/lda user mailbox quota settings (for dovecot)
+# format: number | number 'B' | number 'K' | number 'M' | number 'G'
+#
+# Dovecot supports more than one quota rule (but no way to use a
+# multi-valued attribute). Also add additional attributes for
+# more quota rules even though we're not necessarily
+# using them because we might in the future which could help avoid a
+# schema update. Dovecot supports "as many quota rules as you want"
+
+attributetype ( MiabLDAPmailAttributeType:3
+ DESC 'MDA/LDA user mailbox quota'
+ NAME 'mailboxQuota'
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE
+ EQUALITY caseExactMatch )
+
+attributetype ( MiabLDAPmailAttributeType:4
+ DESC 'MDA/LDA user mailbox quota 2'
+ NAME 'mailboxQuota2'
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE
+ EQUALITY caseExactMatch )
+
+attributetype ( MiabLDAPmailAttributeType:5
+ DESC 'MDA/LDA user mailbox quota 3'
+ NAME 'mailboxQuota3'
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE
+ EQUALITY caseExactMatch )
+
+attributetype ( MiabLDAPmailAttributeType:6
+ DESC 'MDA/LDA user mailbox quota 4'
+ NAME 'mailboxQuota4'
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE
+ EQUALITY caseExactMatch )
+
+attributetype ( MiabLDAPmailAttributeType:7
+ DESC 'MDA/LDA user mailbox quota 5'
+ NAME 'mailboxQuota5'
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE
+ EQUALITY caseExactMatch )
+
+attributetype ( MiabLDAPmailAttributeType:8
+ DESC 'MDA/LDA user mailbox quota 6'
+ NAME 'mailboxQuota6'
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE
+ EQUALITY caseExactMatch )
+
+# Dovecot can maintain a flag indicating whether a user is over or
+# under quota. It's use is not required, but enables postfix to reject
+# messages without queuing them when a mailbox is full. The value
+# should be dovecot boolean value 'yes', or 'no'.
+
+attributetype ( MiabLDAPmailAttributeType:9
+ DESC 'MDA/LDA over quota flag'
+ NAME 'mailboxOverQuotaFlag'
+ SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE
+ EQUALITY caseIgnoreMatch )
+
+
+#
+# object classes
+#
+
objectclass ( 1.3.6.1.4.1.15347.2.1
NAME 'mailUser'
DESC 'E-Mail User'
SUP top
AUXILIARY
MUST ( uid $ mail $ maildrop )
- MAY ( cn $ mailbox $ maildest $ mailaccess )
+ MAY ( cn $ mailbox $ maildest $ mailaccess $ mailboxQuota $ mailboxQuota2 $ mailboxQuota3 $ mailboxQuota4 $ mailboxQuota5 $ mailboxQuota6 $ mailboxOverQuotaFlag )
)
objectclass ( 1.3.6.1.4.1.15347.2.2
diff --git a/management/cli.py b/management/cli.py
index 06b10f91..0708b985 100755
--- a/management/cli.py
+++ b/management/cli.py
@@ -74,6 +74,7 @@ if len(sys.argv) < 2:
{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] (get or set user 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)
@@ -97,6 +98,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"}:
@@ -126,6 +131,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:
+ # Get 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)
@@ -150,4 +163,3 @@ elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
else:
print("Invalid command-line arguments.")
sys.exit(1)
-
diff --git a/management/daemon.py b/management/daemon.py
index 3048ea29..1b521b06 100755
--- a/management/daemon.py
+++ b/management/daemon.py
@@ -30,6 +30,7 @@ import auth, utils
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, set_mail_display_name, 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
from mfa import get_public_mfa_state, enable_mfa, disable_mfa
import mfa_totp
import contextlib
@@ -201,8 +202,31 @@ def mail_users():
@app.route('/mail/users/add', methods=['POST'])
@authorized_personnel_only
def mail_users_add():
+ quota = request.form.get('quota', '0')
try:
- return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), request.form.get('display_name', ''), env)
+ return add_mail_user(request.form.get('email', ''), request.form.get('password', ''), request.form.get('privileges', ''), quota, request.form.get('display_name', ''), 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)
diff --git a/management/mailconfig.py b/management/mailconfig.py
index c5ad39e6..1722c76b 100755
--- a/management/mailconfig.py
+++ b/management/mailconfig.py
@@ -19,8 +19,11 @@
# Python 3 in setup/questions.sh to validate the email
# address entered by the user.
-import subprocess, shutil, os, sqlite3, re, ldap3, uuid, hashlib
-import utils, backend
+import os, sqlite3, re
+import subprocess
+import ldap3, uuid, hashlib, backend
+
+import utils
from email_validator import validate_email as validate_email_, EmailNotValidError
import idna
import socket
@@ -283,6 +286,30 @@ def get_mail_users(env, as_map=False, map_by="maildrop"):
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 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.
@@ -307,20 +334,52 @@ def get_mail_users_ex(env, with_archived=False):
users = []
active_accounts = set()
c = open_database(env)
- response = c.wait( c.search(env.LDAP_USERS_BASE, "(objectClass=mailUser)", attributes=['mail','maildrop','mailaccess','cn']) )
+ response = c.wait( c.search(env.LDAP_USERS_BASE, "(objectClass=mailUser)", attributes=['mail','maildrop','mailaccess','mailboxQuota','cn']) )
for rec in response:
#email = rec['maildrop'][0]
email = rec['mail'][0]
privileges = rec['mailaccess']
+ quota = rec['mailboxQuota'][0] if len(rec['mailboxQuota'])>0 else '0'
display_name = rec['cn'][0]
active_accounts.add(email)
+
+ (user, domain) = email.split('@')
+ box_size = 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)
+
+ try:
+ percent = (box_size / box_quota) * 100
+ except:
+ percent = 'Error'
+
+ except:
+ box_size = '?'
+ box_quota = '?'
+ percent = '?'
+
+ if quota == '0':
+ percent = ''
+
user = {
"email": email,
"privileges": privileges,
+ "quota": quota,
"status": "active",
- "display_name": display_name
+ "display_name": display_name,
+ "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,
}
+
users.append(user)
# Add in archived accounts.
@@ -337,7 +396,10 @@ def get_mail_users_ex(env, with_archived=False):
"privileges": [],
"status": "inactive",
"mailbox": mbox,
- "display_name": ""
+ "display_name": "",
+ "box_size": '?',
+ "box_quota": '?',
+ "percent": '?',
}
users.append(user)
@@ -697,12 +759,13 @@ def remove_mail_domain(env, domain_idna, validate=True):
return True
-def add_mail_user(email, pw, privs, display_name, env):
+def add_mail_user(email, pw, privs, quota, display_name, env):
# Add a new mail user.
#
# email: the new user's email address (idna)
# pw: the new user's password
# privs: either an array of privilege strings, or a newline
+ # quota: a string (number | number 'M' | number 'G') or None
# separated string of privilege names
# display_name: a string with users givenname and surname (eg "Al Woods")
#
@@ -735,6 +798,22 @@ def add_mail_user(email, pw, privs, display_name, env):
validation = validate_privilege(p)
if validation: return validation
+ if quota is None:
+ quota = '0'
+
+ try:
+ quota = validate_quota(quota)
+ except ValueError as e:
+ return (str(e), 400)
+
+ if quota is None:
+ quota = '0'
+
+ try:
+ quota = validate_quota(quota)
+ except ValueError as e:
+ return (str(e), 400)
+
# get the database
conn = open_database(env)
@@ -773,6 +852,7 @@ def add_mail_user(email, pw, privs, display_name, env):
"maildrop" : email.lower(),
"uid" : uid,
"mailaccess": privs,
+ "mailboxQuota": quota,
"cn": cn,
"sn": sn,
"shadowLastChange": backend.get_shadowLastChanged()
@@ -805,6 +885,10 @@ def add_mail_user(email, pw, privs, display_name, env):
# convert alias's mailMember to member
convert_mailMember(env, conn, dn, email)
+ dovecot_quota_recalc(email)
+
+ dovecot_quota_recalc(email)
+
# Update things in case any new domains are added.
if domain_added:
return kick(env, return_status)
@@ -866,6 +950,51 @@ def validate_login(email, pw, env):
except ldap3.core.exceptions.LDAPInvalidCredentialsResult:
return False
+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]+[GMK]?$', quota):
+ raise ValueError("Invalid quota.")
+
+ return quota
+
+def get_mail_quota(email, env):
+ user = find_mail_user(env, email, ['mailboxQuota'])
+ if user is None:
+ return ("That's not a user (%s)." % email, 400)
+ if len(user['mailboxQuota'])==0:
+ return '0'
+ else:
+ return user['mailboxQuota'][0]
+
+def set_mail_quota(email, quota, env):
+ # validate that password is acceptable
+ quota = validate_quota(quota)
+
+ # update the database
+ conn = open_database(env)
+ user = find_mail_user(env, email, ['mailboxQuota'], conn)
+ if user is None:
+ return ("That's not a user (%s)." % email, 400)
+
+ conn.modify_record(user, { 'mailboxQuota': quota })
+ 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_mail_password(email, env):
# Gets the hashed passwords for a user. In ldap, userPassword is
@@ -1329,12 +1458,12 @@ def remove_mail_alias(address_utf8, env, do_kick=True, auto=None, ignore_if_not_
return return_status
-def add_auto_aliases(aliases, env):
- conn, c = open_database(env, with_connection=True)
- c.execute("DELETE FROM auto_aliases")
- for source, destination in aliases.items():
- c.execute("INSERT INTO auto_aliases (source, destination) VALUES (?, ?)", (source, destination))
- conn.commit()
+# def add_auto_aliases(aliases, env):
+# conn, c = open_database(env, with_connection=True)
+# c.execute("DELETE FROM auto_aliases")
+# for source, destination in aliases.items():
+# c.execute("INSERT INTO auto_aliases (source, destination) VALUES (?, ?)", (source, destination))
+# conn.commit()
def get_system_administrator(env):
return "administrator@" + env['PRIMARY_HOSTNAME']
diff --git a/management/templates/users.html b/management/templates/users.html
index 1dca56cb..0a48bf0d 100644
--- a/management/templates/users.html
+++ b/management/templates/users.html
@@ -6,6 +6,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
@@ -34,8 +35,12 @@
Display Name
+
+
Quota
+
+
-
+
@@ -44,13 +49,17 @@
Use aliases to create email addresses that forward to existing accounts.
Administrators get access to this control panel.
User accounts cannot contain any international (non-ASCII) characters, but aliases can.
+
Quotas may not contain any spaces, commas or decimal points. Suffixes of G (gigabytes) and M (megabytes) are allowed. For unlimited storage enter 0 (zero)
");
return false; // cancel click
}
diff --git a/setup/bootstrap.sh b/setup/bootstrap.sh
index 69693455..46995595 100644
--- a/setup/bootstrap.sh
+++ b/setup/bootstrap.sh
@@ -133,7 +133,7 @@ fi
if [ -z "${ENCRYPTION_AT_REST:-}" ]; then
source ehdd/ehdd_funcs.sh || exit 1
hdd_exists && ENCRYPTION_AT_REST=true
-elif [ "${ENCRYPTION_AT_REST:-}" = "false" ]; then
+elif [ "${ENCRYPTION_AT_REST:-}" = "false" ]; then
source ehdd/ehdd_funcs.sh || exit 1
if hdd_exists; then
echo "Encryption-at-rest must be disabled manually"
@@ -147,4 +147,3 @@ if [ "${ENCRYPTION_AT_REST:-false}" = "true" ]; then
else
setup/start.sh "$ldif" </dev/null
rm -f "$ldif"
fi
- done
+ done
}
@@ -481,7 +481,7 @@ EOF
add_overlays() {
# Apply slapd overlays - apply the commonly used member-of overlay
# now because adding it later is harder.
-
+
# Get the config dn for the database
get_attribute "cn=config" "olcSuffix=${LDAP_BASE}" "dn"
[ -z "$ATTR_DN" ] &&
@@ -498,7 +498,7 @@ add: olcModuleLoad
olcModuleLoad: memberof.la
EOF
fi
-
+
get_attribute "$cdn" "(olcOverlay=memberof)" "olcOverlay"
if [ -z "$ATTR_DN" ]; then
say_verbose "Adding memberof overlay to $LDAP_BASE"
@@ -516,7 +516,7 @@ EOF
add_indexes() {
# Index mail-related attributes
-
+
# Get the config dn for the database
get_attribute "cn=config" "olcSuffix=${LDAP_BASE}" "dn"
[ -z "$ATTR_DN" ] &&
@@ -678,7 +678,7 @@ EOF
#
process_cmdline() {
[ -e "$MIAB_INTERNAL_CONF_FILE" ] && . "$MIAB_INTERNAL_CONF_FILE"
-
+
if [ "$1" == "-d" ]; then
# Start slapd in interactive/debug mode
echo "!! SERVER DEBUG MODE !!"
@@ -688,7 +688,7 @@ process_cmdline() {
echo "Listening on $SLAPD_SERVICES..."
/usr/sbin/slapd -h "$SLAPD_SERVICES" -g openldap -u openldap -F $MIAB_SLAPD_CONF -d ${2:-1}
exit 0
-
+
elif [ "$1" == "-config" ]; then
# Apply a certain configuration
if [ "$2" == "server" ]; then
@@ -734,7 +734,7 @@ process_cmdline() {
local hide_attrs="(structuralObjectClass|entryUUID|creatorsName|createTimestamp|entryCSN|modifiersName|modifyTimestamp)"
local slapcat_args=(-F "$MIAB_SLAPD_CONF" -o ldif-wrap=no)
[ ${verbose:-0} -gt 0 ] && hide_attrs="(_____NEVERMATCHES)"
-
+
if [ "$s" == "all" ]; then
echo ""
echo '--------------------------------'
@@ -777,7 +777,7 @@ process_cmdline() {
if [ "$s" == "permitted-senders" -o "$s" == "ps" ]; then
echo ""
echo '--------------------------------'
- local attrs=(mail member mailRoutingAddress rfc822MailMember)
+ local attrs=(mail member mailRoutingAddress mailMember)
[ ${verbose:-0} -gt 0 ] && attrs=()
debug_search "(objectClass=mailGroup)" "$LDAP_PERMITTED_SENDERS_BASE" ${attrs[@]}
fi
@@ -814,7 +814,7 @@ process_cmdline() {
rm -f "/etc/default/slapd"
echo "Done"
exit 0
-
+
elif [ ! -z "$1" ]; then
echo "Invalid command line argument '$1'"
exit 1
diff --git a/setup/mail-dovecot.sh b/setup/mail-dovecot.sh
index a59892fd..072686bf 100755
--- a/setup/mail-dovecot.sh
+++ b/setup/mail-dovecot.sh
@@ -76,6 +76,32 @@ tools/editconf.py /etc/dovecot/conf.d/10-mail.conf \
# 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
+sed -i "s/#mail_plugins =\(.*\)/mail_plugins =\1 \$mail_plugins quota/" /etc/dovecot/conf.d/10-mail.conf
+if ! grep -q "mail_plugins.* imap_quota" /etc/dovecot/conf.d/20-imap.conf; then
+ sed -i "s/\(mail_plugins =.*\)/\1\n mail_plugins = \$mail_plugins imap_quota/" /etc/dovecot/conf.d/20-imap.conf
+fi
+
+# configure stuff for quota support
+if ! grep -q "quota_status_success = DUNNO" /etc/dovecot/conf.d/90-quota.conf; then
+ cat > /etc/dovecot/conf.d/90-quota.conf << EOF;
+plugin {
+ quota = maildir:User quota
+
+ quota_grace = 10%%
+
+ quota_status_success = DUNNO
+ quota_status_nouser = DUNNO
+ quota_status_overquota = "522 5.2.2 Mailbox is full"
+}
+
+service quota-status {
+ executable = quota-status -p postfix
+ inet_listener {
+ port = 12340
+ }
+}
+EOF
+fi
# ### IMAP/POP
diff --git a/setup/mail-postfix.sh b/setup/mail-postfix.sh
index 9c963a45..45631ce5 100755
--- a/setup/mail-postfix.sh
+++ b/setup/mail-postfix.sh
@@ -259,7 +259,7 @@ tools/editconf.py /etc/postfix/main.cf -e lmtp_destination_recipient_limit=
# "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=127.0.1.[2..99]" \
- smtpd_recipient_restrictions="permit_sasl_authenticated,permit_mynetworks,reject_rbl_client zen.spamhaus.org=127.0.0.[2..11],reject_unlisted_recipient,check_policy_service unix:private/policy-spf,check_policy_service inet:127.0.0.1:10023"
+ smtpd_recipient_restrictions="permit_sasl_authenticated,permit_mynetworks,reject_rbl_client zen.spamhaus.org=127.0.0.[2..11],reject_unlisted_recipient,check_policy_service unix:private/policy-spf,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 b9a76635..d7a5fe67 100755
--- a/setup/mail-users.sh
+++ b/setup/mail-users.sh
@@ -88,8 +88,13 @@ pass_attrs = maildrop=user
# Post-login information specific to the user (eg. quotas). For
# lmtp delivery, pass_filter is not used, and postfix has already
# rewritten the envelope using the maildrop address.
+# %$ is expanded to mailboxQuota's value.
user_filter = (&(objectClass=mailUser)(|(mail=%u)(maildrop=%u)))
-user_attrs = maildrop=user
+user_attrs = maildrop=user, \
+ mailboxQuota=quota_rule=*:bytes=%\$, \
+ =quota_rule2=Trash:storage=+100M, \
+ =quota_rule3=Drafts:storage=+25M, \
+ =quota_rule4=Sent:storage=+50M
# Account iteration for various dovecot tools (doveadm)
iterate_filter = (objectClass=mailUser)
@@ -269,3 +274,6 @@ chmod 0640 /etc/postfix/virtual-alias-maps.cf
restart_service postfix
restart_service dovecot
+
+# force a recalculation of all user quotas
+doveadm quota recalc -A
diff --git a/setup/migrate.py b/setup/migrate.py
index 61a9857a..aa47b8e8 100755
--- a/setup/migrate.py
+++ b/setup/migrate.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python3
+#!/usr/bin/python3 -u
# -*- indent-tabs-mode: t; tab-width: 8; python-indent-offset: 8; -*-
#####
##### This file is part of Mail-in-a-Box-LDAP which is released under the
@@ -200,6 +200,12 @@ def migration_14(env):
db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
shell("check_call", ["sqlite3", db, "CREATE TABLE auto_aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);"])
+def migration_15(env):
+ # Add a column to the users table to store their quota limit. Default to '0' for unlimited.
+ db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite')
+ shell("check_call", ["sqlite3", db, "ALTER TABLE users ADD COLUMN quota TEXT NOT NULL DEFAULT '0';"])
+
+
###########################################################
@@ -306,7 +312,7 @@ def migration_miabldap_2(env):
return ldif.replace("rfc822MailMember: ", "mailMember: ")
# apply schema changes miabldap/1 -> miabldap/2
ldap.unbind()
- print("Apply schema changes")
+ print("Apply schema changes to support utf8 email addresses")
m14.apply_schema_changes(env, ldapvars, ldif_change_fn)
# reconnect
ldap = connect(ldapvars)
@@ -329,6 +335,52 @@ def migration_miabldap_2(env):
ldap.unbind()
+def migration_miabldap_3(env):
+ # This migration step changes the ldap schema to support quotas
+ #
+ # possible states at this point:
+ # miabldap was installed and is being upgraded
+ # -> schema update needed
+ # a miab install was present and step 1 upgaded it to miabldap
+ # -> new schema already present
+ #
+ sys.path.append(os.path.realpath(os.path.join(os.path.dirname(__file__), "../management")))
+ import ldap3
+ from backend import connect
+ import migration_14 as m14
+
+ # 1. get ldap site details
+ ldapvars = load_env_vars_from_file(os.path.join(env["STORAGE_ROOT"], "ldap/miab_ldap.conf"), strip_quotes=True)
+
+ # connect before schema changes to ensure admin password works
+ ldap = connect(ldapvars)
+
+ # 2. if this is a miab -> maibldap install, the new schema is
+ # already in place and no schema changes are needed. however,
+ # if this is a miabldap/1 to miabldap/2 migration, we must
+ # upgrade the schema.
+ ret = shell("check_output", [
+ "ldapsearch",
+ "-Q",
+ "-Y", "EXTERNAL",
+ "-H", "ldapi:///",
+ "(&(objectClass=olcSchemaConfig)(cn={*}postfix))",
+ "-b", "cn=schema,cn=config",
+ "-o", "ldif_wrap=no",
+ "-LLL",
+ "olcObjectClasses"
+ ])
+
+ ldap.unbind()
+
+ if "mailboxQuota" not in ret:
+ def ldif_change_fn(ldif):
+ # the schema change we're making does not require any data changes
+ return ldif
+ # apply schema changes miabldap/2 -> miabldap/3
+ print("Apply schema changes to support mailbox quotas")
+ m14.apply_schema_changes(env, ldapvars, ldif_change_fn)
+
def get_current_migration():
ver = 0
@@ -419,7 +471,7 @@ def run_miabldap_migrations():
migration_id = 0
else:
print()
- print("%s file doesn't exists. Skipping migration..." % (migration_id_file,))
+ print("%s file doesn't exist. Skipping migration..." % (migration_id_file,))
return
ourver = int(migration_id)
diff --git a/setup/migration_13.py b/setup/migration_13.py
index d1a49436..e2a771d7 100644
--- a/setup/migration_13.py
+++ b/setup/migration_13.py
@@ -17,7 +17,7 @@
import uuid, os, sqlite3, ldap3, hashlib
-def add_user(env, ldapconn, search_base, users_base, domains_base, email, password, privs, totp, cn=None):
+def add_user(env, ldapconn, search_base, users_base, domains_base, email, password, privs, quota, totp, cn=None):
# Add a sqlite user to ldap
# env are the environment variables
# ldapconn is the bound ldap connection
@@ -27,6 +27,7 @@ def add_user(env, ldapconn, search_base, users_base, domains_base, email, passwo
# email is the user's email
# password is the user's current sqlite password hash
# privs is an array of privilege names for the user
+ # quota is the users mailbox quota (string; defaults to '0')
# totp contains the list of secrets, mru tokens, and labels
# cn is the user's common name [optional]
#
@@ -45,13 +46,14 @@ def add_user(env, ldapconn, search_base, users_base, domains_base, email, passwo
m = hashlib.sha1()
m.update(bytearray(email.lower(),'utf-8'))
uid = m.hexdigest()
-
+
# Attributes to apply to the new ldap entry
objectClasses = [ 'inetOrgPerson','mailUser','shadowAccount' ]
attrs = {
"mail" : email,
"maildrop" : email,
"uid" : uid,
+ "mailboxQuota": quota,
# Openldap uses prefix {CRYPT} for all crypt(3) formats
"userPassword" : password.replace('{SHA512-CRYPT}','{CRYPT}')
}
@@ -91,10 +93,10 @@ def add_user(env, ldapconn, search_base, users_base, domains_base, email, passwo
attrs['totpMruToken'] = totp["mru_token"]
attrs['totpMruTokenTime'] = totp["mru_token_time"]
attrs['totpLabel'] = totp["label"]
-
+
# Add user
dn = "uid=%s,%s" % (uid, users_base)
-
+
print("adding user %s" % email)
ldapconn.add(dn, objectClasses, attrs)
@@ -116,14 +118,15 @@ def create_users(env, conn, ldapconn, ldap_base, ldap_users_base, ldap_domains_b
# select users
c = conn.cursor()
- c.execute("SELECT id, email, password, privileges from users")
+ c.execute("SELECT id, email, password, privileges, quota from users")
users = {}
for row in c:
user_id=row[0]
email=row[1]
password=row[2]
- privs=row[3]
+ privs=row[3]
+ quota=row[4]
totp = None
c2 = conn.cursor()
@@ -143,7 +146,7 @@ def create_users(env, conn, ldapconn, ldap_base, ldap_users_base, ldap_domains_b
totp["label"].append("{%s}%s" % (rowidx, row2[2] or ''))
rowidx += 1
- dn = add_user(env, ldapconn, ldap_base, ldap_users_base, ldap_domains_base, email, password, privs.split("\n"), totp)
+ dn = add_user(env, ldapconn, ldap_base, ldap_users_base, ldap_domains_base, email, password, privs.split("\n"), quota, totp)
users[email] = dn
return users
@@ -164,7 +167,7 @@ def create_aliases(env, conn, ldapconn, aliases_base):
cn="%s" % uuid.uuid4()
dn="cn=%s,%s" % (cn, aliases_base)
description="Mail group %s" % alias
-
+
if alias.startswith("postmaster@") or \
alias.startswith("hostmaster@") or \
alias.startswith("abuse@") or \
@@ -172,7 +175,7 @@ def create_aliases(env, conn, ldapconn, aliases_base):
alias == "administrator@" + env['PRIMARY_HOSTNAME']:
description = "Required alias"
- print("adding alias %s" % alias)
+ print("adding alias %s" % alias)
ldapconn.add(dn, ['mailGroup'], {
"mail": alias,
"description": description
@@ -196,7 +199,7 @@ def populate_aliases(conn, ldapconn, users_map, aliases_map):
alias_dn=aliases_map[alias]
members = []
mailMembers = []
-
+
for email in row[1].split(','):
email=email.strip()
if email=="":
@@ -207,13 +210,13 @@ def populate_aliases(conn, ldapconn, users_map, aliases_map):
members.append(aliases_map[email])
else:
mailMembers.append(email)
-
+
print("populate alias group %s" % alias)
changes = {}
if len(members)>0:
changes["member"]=[(ldap3.MODIFY_REPLACE, members)]
if len(mailMembers)>0:
- changes["rfc822MailMember"]=[(ldap3.MODIFY_REPLACE, mailMembers)]
+ changes["rfc822MailMember"]=[(ldap3.MODIFY_REPLACE, mailMembers)]
ldapconn.modify(alias_dn, changes)
diff --git a/setup/webmail.sh b/setup/webmail.sh
index 37293d68..bbcef7a1 100644
--- a/setup/webmail.sh
+++ b/setup/webmail.sh
@@ -214,6 +214,7 @@ cat > $RCM_CONFIG <
EOF
diff --git a/tests/lib/installed-state.sh b/tests/lib/installed-state.sh
index 3b5b1512..47645a60 100644
--- a/tests/lib/installed-state.sh
+++ b/tests/lib/installed-state.sh
@@ -19,16 +19,16 @@ parse_miab_version_string() {
local tmpfile
tmpfile=$(mktemp)
awk -F- '
-/^v[0-9]+\./ { split($1,a,"."); print "MAJOR="substr(a[1],2); print "MINOR="a[2]; print "RELEASE="$2; next }
+/^v[0-9]+\./ { split($1,a,"."); print "MAJOR="substr(a[1],2); print "MINOR="a[2]; print "RELEASE="$2; next }
$1 ~ /^v[0-9]+[a-z]$/ { print "MAJOR="substr($1,2,length($1)-2); print "MINOR="substr($1,length($1))-"a"+1; print "RELEASE="; next }
$1 ~ /^v[0-9]+[A-Z]$/ { print "MAJOR="substr($1,2,length($1)-2); print "MINOR="substr($1,length($1))-"A"+1; print "RELEASE="; next }
-$1 ~ /^v[0-9]+$/ { print "MAJOR="substr($1,2); print "MINOR="; print "RELEASE="; next }
+$1 ~ /^v[0-9]+$/ { print "MAJOR="substr($1,2); print "MINOR="; print "RELEASE="; next }
{ exit 1 }' >> "$tmpfile" <<< "$1"
-
+
if [ $? -ne 0 ]; then
rm -f "$tmpfile"
return 1
@@ -79,7 +79,7 @@ installed_state_capture() {
echo "MAJOR=$MAJOR" >>"$info"
echo "MINOR=$MINOR" >>"$info"
echo "RELEASE=$RELEASE" >>"$info"
-
+
echo "GIT_ORIGIN='$(git remote -v | grep ^origin | grep 'fetch)$' | awk '{print $2}')'" >>"$info"
echo "MIGRATION_VERSION=$([ -e "$STORAGE_ROOT/mailinabox.version" ] && cat "$STORAGE_ROOT/mailinabox.version")" >>"$info"
echo "MIGRATION_ML_VERSION=$([ -e "$STORAGE_ROOT/mailinabox-ldap.version" ] && cat "$STORAGE_ROOT/mailinabox-ldap.version")" >>"$info"
@@ -114,7 +114,7 @@ installed_state_capture() {
return 3
fi
done
-
+
return 0
}
@@ -123,7 +123,7 @@ installed_state_capture() {
installed_state_compare() {
local s1="$1"
local s2="$2"
-
+
local output
local changed="false"
@@ -138,13 +138,15 @@ installed_state_compare() {
RELEASE_A="${RELEASE:-0}"
PROD_A="miab"
grep "mailinabox-ldap" <<<"$GIT_ORIGIN" >/dev/null && PROD_A="miabldap"
-
+ MIGRATION_ML_VERSION_A="${MIGRATION_ML_VERSION:-0}"
+
source "$s2/info.txt"
MAJOR_B="$MAJOR"
MINOR_B="${MINOR:-0}"
RELEASE_B="${RELEASE:-0}"
PROD_B="miab"
grep "mailinabox-ldap" <<<"$GIT_ORIGIN" >/dev/null && PROD_B="miabldap"
+ MIGRATION_ML_VERSION_B="${MIGRATION_ML_VERSION:-0}"
cmptype="${PROD_A}2${PROD_B}"
@@ -155,7 +157,7 @@ installed_state_compare() {
cp "$s1/aliases.json" "$s1/aliases-cmp.json" || changed="true"
cp "$s2/users.json" "$s2/users-cmp.json" || changed="true"
cp "$s2/aliases.json" "$s2/aliases-cmp.json" || changed="true"
-
+
if [ "$cmptype" = "miab2miabldap" ]
then
# user display names is a feature added to MiaB-LDAP that is
@@ -164,7 +166,7 @@ installed_state_compare() {
# alias descriptions is a feature added to MiaB-LDAP that is
# not in MiaB
- grep -v '"description":' "$s2/aliases.json" > "$s2/aliases-cmp.json" || changed="true"
+ grep -v '"description":' "$s2/aliases.json" > "$s2/aliases-cmp.json" || changed="true"
fi
# cmp: v0.54 to current
@@ -176,9 +178,16 @@ installed_state_compare() {
# s2: re-sort aliases
jq -c ".[] | .aliases | sort_by(.address) | .[] | {address:.address, forwards_to:.forwards_to, permitted_senders:.permitted_senders, auto:.auto, description:.description}" "$s2/aliases.json" > "$s2/aliases-cmp.json"
+
+ if [ $MIGRATION_ML_VERSION_A -le 2 -a $MIGRATION_ML_VERSION_B -ge 3 ]; then
+ # miabldap migration level <=2 does not have quota fields, so
+ # remove them from the comparison
+ grep -vE '"(quota|box_quota|box_size|percent)":' "$s2/users-cmp.json" > "$s2/users-cmp2.json" || changed="true"
+ cp "$s2/users-cmp2.json" "$s2/users-cmp.json" && rm -f "$s2/users-cmp2.json"
+ fi
fi
-
-
+
+
#
# users
#
@@ -195,7 +204,7 @@ installed_state_compare() {
#
# aliases
#
-
+
H2 "Aliases"
output="$(diff "$s1/aliases-cmp.json" "$s2/aliases-cmp.json" 2>&1)"
if [ $? -ne 0 ]; then
@@ -241,13 +250,13 @@ installed_state_compare() {
# $4 == "SOA" { print $1" "$3" "$4" "$5" "$6" "$8" "$10" "$12; next } \
# { for(i=1;i<=NF;i++) if (i!=2) printf("%s ",$i); print ""; }' \
# "$s1/zones/$zone" > "$t1"
-
+
# awk '\
# $4 == "RRSIG" || $4 == "NSEC3" { next; } \
# $4 == "SOA" { print $1" "$3" "$4" "$5" "$6" "$8" "$10" "$12; next } \
# { for(i=1;i<=NF;i++) if (i!=2) printf("%s ",$i); print ""; }' \
# "$s2/zones/$zone" > "$t2"
-
+
# output="$(diff "$t1" "$t2" 2>&1)"
# if [ $? -ne 0 ]; then
# echo "CHANGED zone: $zone"
diff --git a/tests/suites/_mgmt-functions.sh b/tests/suites/_mgmt-functions.sh
index 44e2471b..079e7b8d 100644
--- a/tests/suites/_mgmt-functions.sh
+++ b/tests/suites/_mgmt-functions.sh
@@ -34,11 +34,11 @@ mgmt_start() {
MGMT_ADMIN_PW="$(generate_password)"
delete_user "$MGMT_ADMIN_EMAIL"
-
+
record "[Creating a new account with admin rights for management tests]"
create_user "$MGMT_ADMIN_EMAIL" "$MGMT_ADMIN_PW" "admin"
MGMT_ADMIN_DN="$ATTR_DN"
- record "Created: $MGMT_ADMIN_EMAIL at $MGMT_ADMIN_DN"
+ record "Created: $MGMT_ADMIN_EMAIL at $MGMT_ADMIN_DN"
}
mgmt_end() {
@@ -191,7 +191,7 @@ mgmt_assert_privileges_add() {
mgmt_get_totp_token() {
local secret="$1"
local mru_token="$2"
-
+
TOTP_TOKEN="" # this is set to the acquired token on success
# the user would normally give the secret to an authenticator app
@@ -202,7 +202,7 @@ mgmt_get_totp_token() {
record "[Get the current token for the secret '$secret']"
local count=0
-
+
while [ -z "$TOTP_TOKEN" -a $count -lt 10 ]; do
TOTP_TOKEN="$(totp_current_token "$secret" 2>>"$TEST_OF")"
if [ $? -ne 0 ]; then
@@ -218,13 +218,13 @@ mgmt_get_totp_token() {
record "Success: token is '$TOTP_TOKEN'"
return 0
fi
-
+
let count+=1
done
record "Failed: timeout !"
TOTP_TOKEN=""
- return 1
+ return 1
}
mgmt_mfa_status() {
@@ -246,7 +246,7 @@ mgmt_totp_enable() {
# returns 1 if a REST error occured. $REST_ERROR has the message
# returns 2 if some other error occured
#
-
+
local user="$1"
local pw="$2"
local label="$3" # optional
@@ -258,7 +258,7 @@ mgmt_totp_enable() {
if ! mgmt_mfa_status "$user" "$pw"; then
return 1
fi
-
+
TOTP_SECRET="$(/usr/bin/jq -r ".new_mfa.totp.secret" <<<"$REST_OUTPUT")"
if [ $? -ne 0 ]; then
record "Unable to obtain setup totp secret - is 'jq' installed?"
@@ -271,11 +271,11 @@ mgmt_totp_enable() {
else
record "Found TOTP secret '$TOTP_SECRET'"
fi
-
+
if ! mgmt_get_totp_token "$TOTP_SECRET"; then
return 2
fi
-
+
# 2. enable TOTP
record "Enabling TOTP using the secret and token"
if ! mgmt_rest_as_user "POST" "/admin/mfa/totp/enable" "$user" "$pw" "secret=$TOTP_SECRET" "token=$TOTP_TOKEN" "label=$label"; then
@@ -284,7 +284,7 @@ mgmt_totp_enable() {
else
record "Success: POST /mfa/totp/enable: '$REST_OUTPUT'"
fi
-
+
return 0
}
@@ -318,7 +318,7 @@ mgmt_mfa_disable() {
local user="$1"
local pw="$2"
local mfa_id="$3"
-
+
record "[Disable MFA for $user]"
if [ "$mfa_id" == "all" ]; then
mfa_id=""
@@ -327,7 +327,7 @@ mgmt_mfa_disable() {
if ! mgmt_mfa_status "$user" "$pw"; then
return 1
fi
-
+
mfa_id="$(/usr/bin/jq -r ".enabled_mfa[0].id" <<<"$REST_OUTPUT")"
if [ $? -ne 0 ]; then
record "Unable to use /usr/bin/jq - is it installed?"
@@ -338,9 +338,9 @@ mgmt_mfa_disable() {
return 3
fi
fi
-
-
+
+
if ! mgmt_rest_as_user "POST" "/admin/mfa/disable" "$user" "$pw" "mfa-id=$mfa_id"
then
REST_ERROR="Failed: POST /admin/mfa/disable: $REST_ERROR"
@@ -387,7 +387,7 @@ mgmt_assert_admin_login() {
if [ $code -ne 0 ]; then
test_failure "Unable to run jq ($code) on /admin/login json"
return 1
-
+
elif [ "$status" == "null" ]; then
test_failure "No 'status' in /admin/login json"
return 1
@@ -395,8 +395,39 @@ mgmt_assert_admin_login() {
elif [ "$status" != "$expected_status" ]; then
test_failure "Expected a login status of '$expected_status', but got '$status'"
return 1
-
+
fi
fi
return 0
}
+
+
+mgmt_get_user_quota() {
+ local user="$1"
+ record "[get user $user quota]"
+ mgmt_rest GET "/admin/mail/users/quota?email=$user"
+ local rc=$?
+ # REST_OUTPUT contains json, eg:
+ # { "email": "alice@somedomain.com", "quota": "5000" }
+ if [ $rc -eq 0 ]; then
+ # output to stdout the quota value
+ QUOTA="$(/usr/bin/jq -r ".quota" <<<"$REST_OUTPUT" 2>>$TEST_OF)"
+ if [ $? -ne 0 ]; then
+ record "could not obtain quota member from json using jq"
+ rc=1
+ fi
+ else
+ QUOTA="error"
+ fi
+ return $rc
+
+}
+
+mgmt_set_user_quota() {
+ local user="$1"
+ local quota="$2"
+ record "[set user $user quota to $quota]"
+ mgmt_rest POST "/admin/mail/users/quota" "email=$user" "quota=$quota"
+ local rc=$?
+ return $rc
+}
diff --git a/tests/suites/management-users.sh b/tests/suites/management-users.sh
index 01344a28..0487c76b 100644
--- a/tests/suites/management-users.sh
+++ b/tests/suites/management-users.sh
@@ -8,7 +8,7 @@
##### details.
#####
-#
+#
# User management tests
_test_mixed_case() {
@@ -29,16 +29,16 @@ _test_mixed_case() {
test_failure "Creation of a user with the same email address, but different case, succeeded."
test_failure "${REST_ERROR}"
fi
-
+
# create an alias group with alice in it
mgmt_assert_create_alias_group "${aliases[0]}" "${alices[1]}"
fi
# create local user bob
mgmt_assert_create_user "${bobs[0]}" "$bob_pw"
-
+
assert_check_logs
-
+
# send mail from bob to alice
#
@@ -60,7 +60,7 @@ _test_mixed_case() {
output="$($PYMAIL -subj "$subject" -no-send $PRIVATE_IP ${alices[3]} "$alice_pw" 2>&1)"
assert_python_success $? "$output"
assert_check_logs
-
+
# send mail from alice as the alias to bob, ensure bob got it
#
record "[Mailing to bob as alias from alice]"
@@ -87,7 +87,7 @@ test_mixed_case_users() {
# send mail from that user as the alias to the other user
test_start "mixed-case-users"
-
+
local alices=(alice@mgmt.somedomain.com
aLICE@mgmt.somedomain.com
aLiCe@mgmt.somedomain.com
@@ -102,7 +102,7 @@ test_mixed_case_users() {
ALICE@mgmt.anotherdomain.com)
_test_mixed_case "${alices[*]}" "${bobs[*]}" "${aliases[*]}"
-
+
test_end
}
@@ -115,7 +115,7 @@ test_mixed_case_domains() {
# send mail from that user as the alias to the other user
test_start "mixed-case-domains"
-
+
local alices=(alice@mgmt.somedomain.com
alice@MGMT.somedomain.com
alice@mgmt.SOMEDOMAIN.com
@@ -128,9 +128,9 @@ test_mixed_case_domains() {
local aliases=(alice@MGMT.anotherdomain.com
alice@mgmt.ANOTHERDOMAIN.com
alice@Mgmt.AnotherDomain.Com)
-
+
_test_mixed_case "${alices[*]}" "${bobs[*]}" "${aliases[*]}"
-
+
test_end
}
@@ -178,7 +178,7 @@ test_intl_domains() {
[ ! -z "$ATTR_DN" ] && record_search "$ATTR_DN"
else
record_search "$ATTR_DN"
-
+
# required aliases are automatically created and should
# have both mail addresses (idna and utf8)
get_attribute "$LDAP_ALIASES_BASE" "(mail=abuse@$intl_person_idna_domain)" "mail"
@@ -191,7 +191,7 @@ test_intl_domains() {
test_failure "Require alias abuse@$intl_person_idna_domain expected to contain both idna and utf8 mail addresses"
record_search "$ATTR_DN"
fi
-
+
# ensure user is removed as is expected by the remaining tests
mgmt_delete_user "$intl_person_idna"
fi
@@ -204,7 +204,7 @@ test_intl_domains() {
test_failure "No required alias should not exist for the $intl_person_domain domain"
record_search "$ATTR_DN"
fi
-
+
# create local users bob and mary
mgmt_assert_create_user "$bob" "$bob_pw"
mgmt_assert_create_user "$mary" "$mary_pw"
@@ -212,7 +212,7 @@ test_intl_domains() {
# create intl alias with local user bob and intl_person in it
if mgmt_assert_create_alias_group "$alias" "$bob" "$intl_person"; then
# examine LDAP server to verify IDNA-encodings
-
+
# 1. the mail attribute for the alias should have both the
# idna and utf8 addresses
get_attribute "$LDAP_ALIASES_BASE" "(mail=$alias)" "mail"
@@ -238,7 +238,7 @@ test_intl_domains() {
# re-create intl alias with local user bob only
mgmt_assert_create_alias_group "$alias" "$bob"
-
+
assert_check_logs
if ! have_test_failures; then
@@ -300,18 +300,18 @@ test_totp() {
record "Expect a login failure..."
mgmt_assert_admin_login "$alice" "$alice_pw" "missing-totp-token"
fi
-
+
# logging into /admin/me with a password and a token should
# succeed, and an api_key generated
local api_key
- if ! have_test_failures; then
+ if ! have_test_failures; then
record "Try using a password and a token to get the user api key, we may have to wait 30 seconds to get a new token..."
local old_totp_token="$TOTP_TOKEN"
if ! mgmt_get_totp_token "$TOTP_SECRET" "$TOTP_TOKEN"; then
test_failure "Could not obtain a new TOTP token"
-
+
else
# we have a new token, try logging in ...
# the token must be placed in the header "x-auth-token"
@@ -331,7 +331,7 @@ test_totp() {
fi
# we should be able to login using the user's api key
- if ! have_test_failures; then
+ if ! have_test_failures; then
record "[Use the session key to enum users]"
if ! mgmt_rest_as_user "GET" "/admin/mail/users?format=json" "$alice" "$api_key"; then
test_failure "Unable to use the session key to issue a rest call: $REST_ERROR"
@@ -342,7 +342,7 @@ test_totp() {
# disable totp on the account - login should work with just the password
# and the ldap entry should not have the 'totpUser' objectClass
- if ! have_test_failures; then
+ if ! have_test_failures; then
if mgmt_assert_mfa_disable "$alice" "$api_key"; then
mgmt_assert_admin_login "$alice" "$alice_pw" "ok"
fi
@@ -354,19 +354,130 @@ test_totp() {
else
check_logs
fi
-
+
mgmt_assert_delete_user "$alice"
test_end
}
+test_mailbox_quotas() {
+ test_start "mailbox-quotas"
+
+ # create standard user alice
+ local alice="alice@somedomain.com"
+ create_user "$alice" "alice"
+
+ # quota should be unlimited for newly added users
+ if ! mgmt_get_user_quota "$alice"; then
+ test_failure "Unable to get $alice's quota: $REST_ERROR"
+ elif [ "$QUOTA" != "0" -a "$QUOTA" != "unlimited" ]; then
+ test_failure "A newly created user should have unlimited quota"
+ fi
+
+ # get alice's current total number of messages. should be 0 unless
+ # the account was "archived"
+ local count_messages="$(doveadm -f json quota get -u "$alice" | jq -r '.[] | select(.type=="MESSAGE") | .value')"
+ record "$alice currently has $count_messages messages"
+
+ # set alice's quota to a small number
+ local quota_value="5K"
+ if ! mgmt_set_user_quota "$alice" "$quota_value"
+ then
+ test_failure "Unable to set $alice's quota: $REST_ERROR"
+ else
+ # read back the quota - make sure it's what we set
+ if ! mgmt_get_user_quota "$alice" || [ "$QUOTA" != "$quota_value" ]
+ then
+ test_failure "Setting quota failed - expected quota does not match current quota: $REST_OUTPUT $REST_ERROR QUOTA=$QUOTA"
+
+ else
+ record_search "(mail=$alice)"
+ fi
+ fi
+
+ if ! have_test_failures; then
+ # send messages large enough to exceed the quota
+ local output
+ local subjects=()
+ local msgidx=0
+ local body="$(python3 -c 'for i in range(0,int(512/4)): print("abc\n", end="")')"
+ local quota_exceeded="no"
+
+ while ! have_test_failures && [ $msgidx -lt 10 ]; do
+ record ""
+ record "[send msg $msgidx]"
+ local subj="msg$msgidx - $(generate_password)"
+ output="$($PYMAIL -smtp-debug -body-from-stdin -no-delete -subj "$subj" $PRIVATE_IP $alice alice <<<"$body" 2>&1)"
+ if ! assert_python_success $? "$output"; then
+ break
+ fi
+
+ # You'd expect that the send would fail when the quota is
+ # exceeded, but it doesn't. Postfix accepts it into it's
+ # queue, then bounces the message back to sender with
+ # delivery status notification (DSN) of 5.2.2 when it
+ # processes the queue.
+ #
+ # The debugging messages (turned on by the -smtp-debug
+ # argument) hold the internal postfix message id, so
+ # extract that, then grep the logs to see if the message
+ # was bounced due to 5.2.2.
+
+ local postid="$(awk '/^data: .* queued as/ { match($0," as "); print substr($0,RSTART+4,10); exit }' <<<"$output" 2>>$TEST_OF)"
+ record "Extracted POSTID=$postid"
+ if [ ! -z "$postid" ]; then
+ /usr/sbin/postqueue -f >>"$TEST_OF" 2>&1
+ flush_logs
+ record "[dovecot and postfix logs for msg $msgidx]"
+ record "logs: $(grep "$postid" /var/log/mail.log)"
+
+ if grep "$postid" /var/log/mail.log | grep "status=bounced" | grep -Fq "5.2.2"; then
+ # success - message was rejected
+ quota_exceeded="yes"
+ break
+ fi
+ fi
+
+ subjects+=( "$subj" )
+ let msgidx+=1
+ # doveadm quota get -u "$alice" >>"$TEST_OF" 2>&1
+ done
+
+ if ! have_test_failures && [ "$quota_exceeded" = "no" ]; then
+ test_failure "Quota restriction was not enforced by dovecot after sending $msgidx messages"
+ fi
+
+ # cleanup: delete the messages
+ msgidx=0
+ for subj in "${subjects[@]}"; do
+ record "[delete msg $msgidx]"
+ record "subj=$subj"
+ $PYMAIL -no-send -timeout 2 -subj "$subj" $PRIVATE_IP $alice alice >>$TEST_OF 2>&1
+ let msgidx+=1
+ done
+
+ # verify cleanup worked
+ local cur_count_messages="$(doveadm -f json quota get -u "$alice" | jq -r '.[] | select(.type=="MESSAGE") | .value')"
+ if [ $count_messages -ne $cur_count_messages ]; then
+ test_failure "Cleanup failed: test account $alice started with $count_messages but ended up with $cur_count_messages"
+ fi
+ fi
+
+ # cleanup: delete the test user
+ delete_user "$alice"
+ test_end
+}
+
+
+
+
suite_start "management-users" mgmt_start
test_totp
test_mixed_case_domains
test_mixed_case_users
test_intl_domains
+test_mailbox_quotas
suite_end mgmt_end
-
diff --git a/tests/system-setup/setup-defaults.sh b/tests/system-setup/setup-defaults.sh
index 028325b9..161cc814 100755
--- a/tests/system-setup/setup-defaults.sh
+++ b/tests/system-setup/setup-defaults.sh
@@ -64,7 +64,7 @@ export UPSTREAM_TAG="${UPSTREAM_TAG:-}"
# For setup scripts that install miabldap releases (eg. upgrade tests)
export MIABLDAP_GIT="${MIABLDAP_GIT:-https://github.com/downtownallday/mailinabox-ldap.git}"
-export MIABLDAP_RELEASE_TAG="${MIABLDAP_RELEASE_TAG:-v60}"
+export MIABLDAP_RELEASE_TAG="${MIABLDAP_RELEASE_TAG:-v70}"
# When running tests that require php, use this version of php. This
# should be the same as what's in setup/functions.sh.
@@ -73,3 +73,4 @@ export PHP_VER=8.0
# Tag of last version supported on Ubuntu Bionic 18.04
UPSTREAM_FINAL_RELEASE_TAG_BIONIC64=v57a
MIABLDAP_FINAL_RELEASE_TAG_BIONIC64=f6cd8f56c3bcb20969c6cf66c040c8efa3773f3a
+MIABLDAP_INITIAL_RELEASE_TAG_JAMMY64=v60
diff --git a/tests/test_mail.py b/tests/test_mail.py
index a7bd1e0e..68bae37c 100755
--- a/tests/test_mail.py
+++ b/tests/test_mail.py
@@ -28,6 +28,8 @@ def usage():
print(" -no-send: don't send, just delete")
print(" -no-delete: don't delete, just send")
print(" -timeout : how long to wait for message")
+ print(" -body-from-stdin: read the message body from stdin")
+ print(" -smtp-debug: output debugging messages")
print("");
sys.exit(1)
@@ -48,6 +50,8 @@ delete_msg=True # login to imap and delete message
wait_timeout=30 # abandon timeout wiating for message delivery
wait_cycle_sleep=5 # delay between delivery checks
subject="Mail-in-a-Box Automated Test Message " + uuid.uuid4().hex # message subject
+body_from_stdin=False
+smtp_debug=False
# process command line
argi=1
@@ -81,6 +85,12 @@ while argi1:
wait_timeout=int(sys.argv[argi+1])
argi+=2
+ elif arg=="-body-from-stdin":
+ body_from_stdin = True
+ argi+=1
+ elif arg=="-smtp-debug":
+ smtp_debug = True
+ argi+=1
else:
usage()
@@ -100,14 +110,20 @@ headerfrom = if_unset(headerfrom, emailfrom)
emailto = if_unset(emailto, login)
emailto_pw = if_unset(emailto_pw, pw)
+if body_from_stdin:
+ body=sys.stdin.readlines()
+else:
+ body=['This is a test message. It should be automatically deleted by the test script.']
+
msg = """From: {headerfrom}
To: {emailto}
Subject: {subject}
-This is a test message. It should be automatically deleted by the test script.""".format(
+{body}""".format(
headerfrom=headerfrom,
emailto=emailto,
subject=subject,
+ body=''.join(body)
)
def imap_login(host, login, pw):
@@ -162,7 +178,8 @@ def smtp_login(host, login, pw, port):
server.starttls()
else:
server = smtplib.SMTP_SSL(host)
- #server.set_debuglevel(1)
+ if smtp_debug:
+ server.set_debuglevel(1)
# Verify that the EHLO name matches the server's reverse DNS.
ipaddr = socket.gethostbyname(host) # IPv4 only!
@@ -191,7 +208,10 @@ def smtp_login(host, login, pw, port):
if send_msg:
# Attempt to send a mail.
server = smtp_login(host, login, pw, port)
- server.sendmail(emailfrom, [emailto], msg)
+ # sendmail: "If this method does not raise an exception, it returns a dictionary, with one entry for each recipient that was refused. Each entry contains a tuple of the SMTP error code and the accompanying error message sent by the server."
+ errors = server.sendmail(emailfrom, [emailto], msg)
+ #print(errors)
+ #if errors: raise ValueError(errors)
server.quit()
print("SMTP submission is OK.")