Add files via upload

This commit is contained in:
Harold Finch
2023-04-10 07:18:32 +02:00
committed by GitHub
parent 06ddbf431f
commit 65875d8fef
100 changed files with 84692 additions and 42 deletions

5
libs/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
__author__ = "Zhang Huangbin"
__author_mail__ = "zhb@iredmail.org"
__version_ldap__ = "5.4"
__version_sql__ = "5.3"
__url_license_terms__ = "http://www.iredmail.org/pricing.html#EULA"

56
libs/amavisd/__init__.py Normal file
View File

@@ -0,0 +1,56 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
from libs import iredutils
# mail_id and secret_id are composed of below characters:
# - Amavisd-new-2.7+: [ A-Z, a-z, 0-9, -, _ ]
# - Amavisd-new-2.6.x: [ A-Z, a-z, 0-9, +, - ]
MAIL_ID_CHARACTERS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+-_'
WBLIST_FORM_INPUT_NAMES = {'wl_sender': 'whitelistSender',
'bl_sender': 'blacklistSender',
'wl_rcpt': 'whitelistRecipient',
'bl_rcpt': 'blacklistRecipient'}
# Available quarantined types in iRedAdmin web interface, and the short code
# in `amavisd.msgs` sql table.
QUARANTINE_TYPES = {'spam': 'S',
'virus': 'V',
'banned': 'B',
'clean': 'C',
'badheader': 'H',
'badmime': 'M'}
# Value of `msgs.content` and comment.
CONTENT_TYPES = {'B': 'Banned',
'C': 'Clean',
'H': 'Bad header',
'M': 'Bad mime',
'O': 'Oversized',
'S': 'Spam',
'T': 'MTA error',
'V': 'Virus',
'U': 'Unchecked'}
def get_wblist_from_form(form, form_input_name):
# Available form_input_name are listed in WBLIST_FORM_INPUT_NAMES
input_name = WBLIST_FORM_INPUT_NAMES[form_input_name]
addresses = []
for _line in form.get(input_name, '').splitlines():
if _line:
try:
_line = str(_line)
addresses.append(_line)
except:
pass
valid_addresses = []
for addr in addresses:
if iredutils.is_valid_wblist_address(addr) and (addr not in valid_addresses):
valid_addresses.append(addr)
else:
continue
return valid_addresses

572
libs/amavisd/log.py Normal file
View File

@@ -0,0 +1,572 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import time
import web
import settings
from libs import iredutils
from libs.logger import logger, log_traceback
from libs.amavisd import MAIL_ID_CHARACTERS
session = web.config.get('_session')
# Import backend related modules.
if settings.backend == 'ldap':
from libs.ldaplib import admin as ldap_lib_admin
elif settings.backend in ['mysql', 'pgsql']:
from libs.sqllib import admin as sql_lib_admin
def delete_all_records(log_type=None, account=None):
# Delete all records, or delete records older than one week.
# :param log_type: sent, received
# :param account: single email address, domain name, '@.'
account_is_email = False
account_is_domain = False
maddr_ids = []
managed_domains_reversed = []
if account:
if iredutils.is_email(account):
account_is_email = True
elif iredutils.is_domain(account):
account_is_domain = True
else:
if account == '@.':
pass
else:
return False, 'INVALID_ACCOUNT'
# get `maddr.id` of this account
if account_is_email:
# user
qr = web.conn_amavisd.select('maddr',
vars={'account': account},
what='id',
where='email=$account',
limit=1)
if qr:
maddr_ids.append(qr[0].id)
elif account_is_domain:
# domain
reversed_domain = iredutils.reverse_amavisd_domain_names([account])[0]
qr = web.conn_amavisd.select('maddr',
vars={'account': reversed_domain},
what='id',
where='domain=$account')
if qr:
for r in qr:
maddr_ids.append(r.id)
# no `maddr.id`, no mail log.
if not maddr_ids:
return True,
else:
if session.get('is_global_admin'):
web.conn_amavisd.delete('msgs', where='1=1')
web.conn_amavisd.delete('msgrcpt', where='1=1')
return True,
# Get all managed domains by normal admin.
managed_domains = []
if settings.backend == 'ldap':
_qr = ldap_lib_admin.get_managed_domains(admin=session.get('username'))
if _qr[0]:
managed_domains = _qr[1]
elif settings.backend in ['mysql', 'pgsql']:
qr = sql_lib_admin.get_managed_domains(admin=session.get('username'),
domain_name_only=True)
if qr[0]:
managed_domains = qr[1]
else:
return False, 'UNKNOWN_BACKEND'
managed_domains_reversed = iredutils.reverse_amavisd_domain_names(managed_domains)
if not managed_domains_reversed:
return True,
try:
# Delete records in tables: msgs, msgrcpt.
if log_type == 'sent':
if account:
# Delete all records sent by single user
web.conn_amavisd.delete('msgs',
vars={'maddr_ids': maddr_ids},
where='sid IN $maddr_ids')
else:
# Delete all records sent by domain users
web.conn_amavisd.delete('msgs',
vars={'managed_domains_reversed': managed_domains_reversed},
where='sid IN (SELECT id FROM maddr WHERE domain IN $managed_domains_reversed)')
elif log_type == 'received':
if account:
web.conn_amavisd.delete('msgs',
vars={'maddr_ids': maddr_ids},
where='mail_id IN (SELECT mail_id FROM msgrcpt WHERE rid IN $maddr_ids)')
web.conn_amavisd.delete('msgrcpt',
vars={'maddr_ids': maddr_ids},
where='rid IN $maddr_ids')
else:
all_rcpt_ids = [] # maddr.id
qr = web.conn_amavisd.select('maddr',
vars={'domains': managed_domains_reversed},
what='id',
where='domain IN $domains')
for i in qr:
all_rcpt_ids.append(i['id'])
del qr
web.conn_amavisd.delete('msgs',
vars={'ids': all_rcpt_ids},
where='mail_id IN (SELECT mail_id FROM msgrcpt WHERE rid IN $ids)')
web.conn_amavisd.delete('msgrcpt',
vars={'ids': all_rcpt_ids},
where='rid IN $ids')
del all_rcpt_ids
return True,
except Exception as e:
return False, repr(e)
def delete_records_by_mail_id(log_type='sent', mail_ids=None):
# log_type -- received, sent, quarantined, quarantine
if not isinstance(mail_ids, list):
return False, 'INCORRECT_MAILID'
# Filter unexpected mail_id strings.
mail_ids = [v for v in mail_ids if len(set(v) - set(MAIL_ID_CHARACTERS)) == 0]
if not mail_ids:
return True,
# Converted into SQL style list.
mail_ids = web.sqlquote(mail_ids)
if log_type in ['received', 'sent', 'quarantined', 'quarantine']:
try:
# Delete records in tables: msgs, msgrcpt.
web.conn_amavisd.delete('msgs', where='mail_id IN %s' % mail_ids)
web.conn_amavisd.delete('msgrcpt', where='mail_id IN %s' % mail_ids)
except Exception as e:
return False, repr(e)
if log_type in ['quarantined', 'quarantine']:
try:
web.conn_amavisd.delete('quarantine', where="mail_id IN %s" % mail_ids)
except Exception as e:
return False, repr(e)
return True,
def count_incoming_mails(reversedDomainNames=None,
timeLength=None,
sqlAppendWhere=None):
# timeLength is seconds.
total = 0
if not reversedDomainNames:
if not session.get('account_is_mail_user'):
return total
if sqlAppendWhere:
sql_append_where = sqlAppendWhere
else:
sql_append_where = ' AND recip.domain IN %s' % web.sqlquote(reversedDomainNames)
if isinstance(timeLength, int):
_now = int(time.time())
_length_seconds = _now - timeLength
sql_append_where += ' AND msgs.time_num > %d' % _length_seconds
try:
qr = web.conn_amavisd.query('''
-- Get number of incoming mails.
SELECT COUNT(msgs.mail_id) AS total
FROM msgs
LEFT JOIN msgrcpt ON (msgs.mail_id = msgrcpt.mail_id)
LEFT JOIN maddr AS sender ON (msgs.sid = sender.id)
LEFT JOIN maddr AS recip ON (msgrcpt.rid = recip.id)
WHERE msgs.quar_type <> 'Q' %s
''' % sql_append_where)
total = qr[0].total or 0
except Exception as e:
logger.error(e)
return total
def count_outgoing_mails(reversedDomainNames=None,
timeLength=None,
sqlAppendWhere=None):
# timeLength is seconds.
total = 0
sql_append_where = ''
if not reversedDomainNames:
return total
if sqlAppendWhere:
sql_append_where = sqlAppendWhere
else:
sql_append_where += ' AND sender.domain IN %s' % web.sqlquote(reversedDomainNames)
if isinstance(timeLength, int):
_now = int(time.time())
_length_seconds = _now - timeLength
sql_append_where += ' AND msgs.time_num > %d' % _length_seconds
try:
qr_count = web.conn_amavisd.query("""
-- Get number of outgoing mails.
SELECT COUNT(msgs.mail_id) AS total
FROM msgs
RIGHT JOIN msgrcpt ON (msgs.mail_id = msgrcpt.mail_id)
RIGHT JOIN maddr AS sender ON (msgs.sid = sender.id)
RIGHT JOIN maddr AS recip ON (msgrcpt.rid = recip.id)
WHERE msgs.quar_type <> 'Q' %s""" % sql_append_where)
total = qr_count[0].total or 0
except Exception:
pass
return total
def count_virus_mails(reversedDomainNames=None, timeLength=None):
# timeLength is seconds.
total = 0
sql_append_where = ''
if not reversedDomainNames:
return total
if session.get('is_global_admin') is not True:
sql_append_where += ' AND (sender.domain IN {} OR recip.domain IN {})'.format(
web.sqlquote(reversedDomainNames),
web.sqlquote(reversedDomainNames),
)
if isinstance(timeLength, int):
_now = int(time.time())
_length_seconds = _now - timeLength
sql_append_where += ' AND msgs.time_num > %d' % _length_seconds
try:
qr = web.conn_amavisd.query("""
SELECT COUNT(msgs.mail_id) AS total
FROM msgs
RIGHT JOIN msgrcpt ON (msgs.mail_id = msgrcpt.mail_id)
RIGHT JOIN maddr AS sender ON (msgs.sid = sender.id)
RIGHT JOIN maddr AS recip ON (msgrcpt.rid = recip.id)
WHERE msgs.content = 'V'
AND msgs.quar_type='Q'
%s
""" % sql_append_where)
total = qr[0].total or 0
except Exception:
pass
return total
def count_quarantined(reversedDomainNames=None, timeLength=None):
# timeLength is seconds.
total = 0
sql_append_where = ''
if not session.get('is_global_admin'):
sql_append_where += ' AND (sender.domain IN {} OR recip.domain IN {})'.format(
web.sqlquote(reversedDomainNames),
web.sqlquote(reversedDomainNames),
)
if isinstance(timeLength, int):
_now = int(time.time())
_length_seconds = _now - timeLength
sql_append_where += ' AND msgs.time_num > %d' % _length_seconds
try:
if session.get('is_global_admin'):
qr = web.conn_amavisd.query("""
SELECT COUNT(msgs.mail_id) AS total
FROM msgs
RIGHT JOIN maddr AS sender ON (msgs.sid = sender.id)
WHERE msgs.quar_type = 'Q' %s
""" % sql_append_where)
else:
qr = web.conn_amavisd.query("""
SELECT COUNT(msgs.mail_id) AS total
FROM msgs
RIGHT JOIN msgrcpt ON (msgs.mail_id = msgrcpt.mail_id)
RIGHT JOIN maddr AS sender ON (msgs.sid = sender.id)
RIGHT JOIN maddr AS recip ON (msgrcpt.rid = recip.id)
WHERE msgs.quar_type = 'Q' %s
""" % sql_append_where)
total = qr[0].total or 0
except:
log_traceback()
return total
def get_in_out_mails(log_type='sent',
cur_page=1,
account_type='',
account='',
page_size_limit=None):
"""
@account_type: 'domain', 'user', None
@log_type: 'sent', 'received', 'all'
@return (True, {'count': <int>, 'records': <list>}
"""
log_type = str(log_type)
cur_page = int(cur_page)
account_type = str(account_type) or None
account = str(account) or None
result = {'count': 0, 'records': []}
count = 0 # Number of total mails.
records = {} # Detail records.
sql_append_where = ''
reversed_account = ''
if not page_size_limit:
page_size_limit = settings.PAGE_SIZE_LIMIT
if account_type == 'domain':
reversed_account = iredutils.reverse_amavisd_domain_names([account])
# Get all managed domain names and reversed names.
all_domains = []
allReversedDomainNames = []
quoted_all_reversed_domain_names = []
sql_restricted_sender_domains = ''
sql_restricted_recip_domains = ''
if not session.get('account_is_mail_user'):
if settings.backend == 'ldap':
_qr = ldap_lib_admin.get_managed_domains(admin=session.get('username'))
if _qr[0]:
all_domains = _qr[1]
elif settings.backend in ['mysql', 'pgsql']:
qr_all_domains = sql_lib_admin.get_managed_domains(admin=session.get('username'),
domain_name_only=True)
if qr_all_domains[0]:
all_domains += qr_all_domains[1]
else:
result['count'] = count
result['records'] = list(records)
return True, result
allReversedDomainNames = iredutils.reverse_amavisd_domain_names(all_domains)
quoted_all_reversed_domain_names = web.sqlquote(allReversedDomainNames)
sql_restricted_sender_domains = ' AND sender.domain IN %s' % quoted_all_reversed_domain_names
sql_restricted_recip_domains = ' AND recip.domain IN %s' % quoted_all_reversed_domain_names
# restrict permission for per-account search
# @log_type == 'sent'
# - if domain is under control, no restriction
# - if domain is not under control, restrict recipient domain to managed domains
# @log_type == 'received'
# - if domain is under control, no restriction
# - if domain is not under control, restrict sender domain to managed domains
verify_domain = account
if account_type == 'user':
verify_domain = account.split('@', 1)[-1]
if log_type == 'received':
if account_type == 'domain':
if session.get('is_global_admin') or verify_domain in all_domains:
sql_append_where += ' AND recip.domain IN %s' % web.sqlquote(reversed_account)
else:
sql_append_where += ' {} AND recip.domain IN {}'.format(sql_restricted_sender_domains, web.sqlquote(reversed_account))
elif account_type == 'user':
if session.get('is_global_admin') or verify_domain in all_domains:
sql_append_where += ' AND recip.email=%s' % web.sqlquote(account)
else:
sql_append_where += ' {} AND recip.email={}'.format(sql_restricted_sender_domains, web.sqlquote(account))
else:
if settings.AMAVISD_SHOW_NON_LOCAL_DOMAINS:
if session.get('is_global_admin'):
pass
else:
if not quoted_all_reversed_domain_names:
return True, result
else:
sql_append_where += ' AND recip.domain IN %s' % quoted_all_reversed_domain_names
else:
if not quoted_all_reversed_domain_names:
return True, result
else:
sql_append_where += ' AND recip.domain IN %s' % quoted_all_reversed_domain_names
elif log_type == 'sent':
if account_type == 'domain':
if session.get('is_global_admin') or verify_domain in all_domains:
sql_append_where += ' AND sender.domain IN %s' % web.sqlquote(reversed_account)
else:
sql_append_where += ' {} AND sender.domain IN {}'.format(sql_restricted_recip_domains, web.sqlquote(reversed_account))
elif account_type == 'user':
if session.get('is_global_admin') or verify_domain in all_domains:
sql_append_where += ' AND sender.email = %s' % (web.sqlquote(account))
else:
sql_append_where += ' {} AND sender.email = {}'.format(sql_restricted_recip_domains, web.sqlquote(account))
else:
if settings.AMAVISD_SHOW_NON_LOCAL_DOMAINS:
if session.get('is_global_admin'):
pass
else:
if not quoted_all_reversed_domain_names:
return True, result
else:
sql_append_where += ' AND sender.domain IN %s' % quoted_all_reversed_domain_names
else:
if not quoted_all_reversed_domain_names:
return True, result
else:
sql_append_where += ' AND sender.domain IN %s' % quoted_all_reversed_domain_names
########################
# Get detail records.
#
try:
if log_type == 'received':
count = count_incoming_mails(allReversedDomainNames,
sqlAppendWhere=sql_append_where)
qr = web.conn_amavisd.query(
'''
-- Get records of received mails.
SELECT
msgs.mail_id, msgs.subject, msgs.time_num,
msgs.size, msgs.spam_level, msgs.client_addr, msgs.policy,
sender.email_raw AS sender_email,
recip.email_raw AS recipient
FROM msgs
LEFT JOIN msgrcpt ON (msgs.mail_id = msgrcpt.mail_id)
LEFT JOIN maddr AS sender ON (msgs.sid = sender.id)
LEFT JOIN maddr AS recip ON (msgrcpt.rid = recip.id)
WHERE msgs.quar_type <> 'Q' %s
ORDER BY msgs.time_num DESC
LIMIT %d
OFFSET %d
''' % (sql_append_where,
page_size_limit,
(cur_page - 1) * page_size_limit)
)
records = iredutils.bytes2str(qr)
elif log_type == 'sent':
count = count_outgoing_mails(allReversedDomainNames,
sqlAppendWhere=sql_append_where)
qr = web.conn_amavisd.query(
'''
-- Get records of sent mails.
SELECT
msgs.mail_id, msgs.subject, msgs.time_num,
msgs.size, msgs.client_addr, msgs.policy,
sender.email_raw AS sender_email,
recip.email_raw AS recipient
FROM msgs
RIGHT JOIN msgrcpt ON (msgs.mail_id = msgrcpt.mail_id)
RIGHT JOIN maddr AS sender ON (msgs.sid = sender.id)
RIGHT JOIN maddr AS recip ON (msgrcpt.rid = recip.id)
WHERE msgs.quar_type <> 'Q' %s
ORDER BY msgs.time_num DESC
LIMIT %d
OFFSET %d
''' % (sql_append_where,
page_size_limit,
(cur_page - 1) * page_size_limit)
)
records = iredutils.bytes2str(qr)
else:
records = {}
except:
pass
return True, {'count': count, 'records': list(records)}
def get_top_users(reversedDomainNames=None,
log_type='sent',
timeLength=None,
number=10):
records = {}
sql_append_where = ''
if settings.AMAVISD_SHOW_NON_LOCAL_DOMAINS:
if session.get('is_global_admin'):
pass
else:
if not reversedDomainNames:
return []
else:
if log_type == 'sent':
sql_append_where += ' AND sender.domain IN %s' % web.sqlquote(reversedDomainNames)
elif log_type == 'received':
sql_append_where += ' AND rcpt.domain IN %s' % web.sqlquote(reversedDomainNames)
else:
if log_type == 'sent':
sql_append_where += ' AND sender.domain IN %s' % web.sqlquote(reversedDomainNames)
elif log_type == 'received':
sql_append_where += ' AND rcpt.domain IN %s' % web.sqlquote(reversedDomainNames)
if isinstance(timeLength, int):
_now = int(time.time())
_length_seconds = _now - timeLength
sql_append_where += ' AND msgs.time_num > %d' % _length_seconds
# `msgs.policy` (Amavisd policy bank) is used to identify account type.
# for example, 'MLMMJ' means mlmmj mailing list.
if log_type == 'sent':
try:
result = web.conn_amavisd.query(
"""
-- Get top 10 senders.
SELECT COUNT(msgs.mail_id) AS total,
sender.email_raw AS mail,
msgs.policy AS policy
FROM msgs
RIGHT JOIN maddr AS sender ON (msgs.sid = sender.id)
WHERE 1=1 %s
GROUP BY mail, policy
ORDER BY total DESC
LIMIT %d
""" % (sql_append_where, number))
records = list(result)
except:
log_traceback()
elif log_type == 'received':
try:
result = web.conn_amavisd.query(
"""
-- Get top 10 recipients
SELECT COUNT(msgs.mail_id) AS total,
rcpt.email_raw AS mail
FROM msgs
RIGHT JOIN msgrcpt ON (msgs.mail_id = msgrcpt.mail_id)
RIGHT JOIN maddr AS sender ON (msgs.sid = sender.id)
RIGHT JOIN maddr AS rcpt ON (msgrcpt.rid = rcpt.id)
WHERE 1=1 %s
GROUP BY mail
ORDER BY total DESC
LIMIT %d
""" % (sql_append_where, number))
records = list(result)
except:
log_traceback()
records = iredutils.bytes2str(records)
return list(records)

315
libs/amavisd/quarantine.py Normal file
View File

@@ -0,0 +1,315 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import socket
import web
import settings
from libs import iredutils
from libs.amavisd import QUARANTINE_TYPES
session = web.config.get("_session")
# Import backend related modules.
if settings.backend == "ldap":
from libs.ldaplib.admin import get_managed_domains
elif settings.backend in ["mysql", "pgsql"]:
from libs.sqllib.admin import get_managed_domains
def get_raw_message(mail_id: str) -> bytes:
"""Get raw mail message of quarantined email specified by `mail_id`."""
# TODO Check domain access by sender/recipient of quarantined email
if not mail_id:
return False, "INVALID_MAILID"
try:
records = web.conn_amavisd.select(
"quarantine",
vars={"mail_id": mail_id},
what="mail_text",
where="mail_id=$mail_id",
order="chunk_ind ASC",
)
if not records:
return False, "INVALID_MAILID"
# Combine mail_text as RAW mail message.
# Note: `mail_text` is bytes type.
message = b""
records = list(records)
for i in records:
message += i['mail_text']
return True, message
except Exception as e:
return False, repr(e)
# If msgs.quar_type != "Q" (SQL), we can't get mail body.
def get_quarantined_mails(page=1,
account_type=None,
account="",
quarantined_type="",
size_limit=settings.PAGE_SIZE_LIMIT,
sort_by_score=False):
"""Return ([True | False], (total, records))"""
page = int(page)
account = str(account) or None
# Pre-defined values.
count = 0
records = []
sql_append_selection = ''
# Domain names under control.
all_domains = []
# Query SQL.
if session.get('is_normal_admin'):
# List all managed domains in query if admin is not global admin
qr = get_managed_domains(admin=session.get('username'), domain_name_only=True)
if qr[0]:
all_domains = qr[1]
all_reversed_domains = iredutils.reverse_amavisd_domain_names(all_domains)
if all_domains:
sql_append_selection += ' AND (sender.domain IN {} OR recip.domain IN {})'.format(
web.sqlquote(all_reversed_domains),
web.sqlquote(all_reversed_domains),
)
else:
return True, (0, {})
if account_type == 'domain':
if account:
reversed_account = iredutils.reverse_amavisd_domain_names([account])[0]
if not session.get('is_global_admin'):
# Make sure account is managed domain
if account not in all_domains:
# PERMISSION_DENIED
return True, (0, {})
sql_append_selection += ' AND (sender.domain={} OR recip.domain={})'.format(
web.sqlquote(reversed_account), web.sqlquote(reversed_account),
)
elif account_type == 'user':
if session.get('is_normal_admin'):
# Make sure account is under managed domains
if not account.split('@', 1)[-1] in all_domains:
# PERMISSION_DENIED
return True, (0, {})
elif session.get('account_is_mail_user'):
if account != session['username']:
return True, (0, {})
sql_append_selection += ' AND (sender.email={} OR recip.email={})'.format(
web.sqlquote(account),
web.sqlquote(account),
)
if quarantined_type == 'spam':
sql_append_selection += " AND msgs.content IN ('S', 's', 'Y')"
elif quarantined_type == 'virus':
sql_append_selection += " AND msgs.content = 'V'"
elif quarantined_type == 'banned':
sql_append_selection += " AND msgs.content = 'B'"
elif quarantined_type == 'badheader':
sql_append_selection += " AND msgs.content = 'H'"
elif quarantined_type == 'badmime':
sql_append_selection += " AND msgs.content = 'M'"
# Get number of total records. SQL table: amavisd.msgs
try:
# Refer to templates/default/macros/amavisd.html for more detail
# about msgs.content (content type, spam status), msgs.quar_type
# (quarantine type).
result = web.conn_amavisd.query(
"""
-- Get number of quarantined emails
SELECT COUNT(msgs.mail_id) AS total
FROM msgs
LEFT JOIN msgrcpt ON msgs.mail_id = msgrcpt.mail_id
LEFT JOIN maddr AS sender ON msgs.sid = sender.id
LEFT JOIN maddr AS recip ON msgrcpt.rid = recip.id
WHERE
-- msgs.content IN ('S', 's', 'Y', 'V', 'B', 'H')
-- AND msgs.quar_type = 'Q'
msgs.quar_type = 'Q'
%s
""" % sql_append_selection)
count = result[0].total or 0
except:
pass
# Get records of quarantined mails.
try:
# msgs.content:
# - S: spam(kill)
# - s: prior to 2.7.0 the CC_SPAMMY was logged as 's', now 'Y' is used.
# msgs.quar_type:
# - Q: sql
# - F: file
sort_column = 'msgs.time_num'
if sort_by_score:
sort_column = 'msgs.spam_level'
result = web.conn_amavisd.query(
'''
-- Get records of quarantined mails.
SELECT
msgs.mail_id, msgs.secret_id, msgs.subject, msgs.time_num,
msgs.content, msgs.size, msgs.spam_level,
sender.email AS sender_email,
recip.email AS recipient
FROM msgs
LEFT JOIN msgrcpt ON msgs.mail_id = msgrcpt.mail_id
LEFT JOIN maddr AS sender ON msgs.sid = sender.id
LEFT JOIN maddr AS recip ON msgrcpt.rid = recip.id
WHERE
-- msgs.content IN ('S', 's', 'Y', 'V', 'B', 'H')
-- AND msgs.quar_type = 'Q'
msgs.quar_type = 'Q'
%s
ORDER BY %s DESC
LIMIT %d
OFFSET %d
''' % (sql_append_selection, sort_column, size_limit, (page - 1) * size_limit)
)
records = iredutils.bytes2str(result)
except:
pass
return True, (count, records)
def delete_all_quarantined(quarantined_type=None):
if quarantined_type in QUARANTINE_TYPES:
_content = QUARANTINE_TYPES[quarantined_type]
# Delete them from `msgs`.
# Records in `quarantine` will be cleaned up by cron job
try:
web.conn_amavisd.delete(
'msgs',
vars={'quar_type': 'Q', 'content': _content},
where='quar_type=$quar_type AND content=$content',
)
return True,
except Exception as e:
return False, repr(e)
else:
try:
web.conn_amavisd.delete('quarantine', where='1=1')
web.conn_amavisd.delete('msgs', where="""quar_type='Q'""")
return True,
except Exception as e:
return False, repr(e)
def release_quarantined_mails(records=None):
# Release quarantined mails.
#
# records = [
# {'mail_id': 'xxx',
# 'secret_id': 'yyy',
# 'requested_by': session.get('username'),
# },
# [],
# ]
#
# Refer to amavisd doc 'README.protocol' for more detail:
# - Releasing a message from a quarantine
if not records:
return True,
# TODO Check domain_access
# - Get managed domains.
# - Check whether mail_id in `records` are one of managed domains.
# - Get allowed mail_id list.
# Pre-defined variables.
released_mail_ids = []
# Create socket.
try:
quar_server = settings.amavisd_db_host
quar_port = int(settings.amavisd_quarantine_port)
if settings.AMAVISD_QUARANTINE_HOST:
quar_server = settings.AMAVISD_QUARANTINE_HOST
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((quar_server, quar_port))
except Exception as e:
return False, repr(e)
# Generate commands from dict, used for socket communication.
# Note: We need to update Amavisd SQL database after mail was released
# with success, so do NOT send all release requests in ONE socket
# command although it will get better performance (a little).
for record in records:
# Skip record without 'mail_id'.
if 'mail_id' not in record:
continue
cmd_release = 'request=release\r\n'
for k in record:
if record[k] is not None and record[k] != '':
cmd_release += '{}={}\r\n'.format(k, record[k])
cmd_release += 'quar_type=Q\r\n\r\n'
try:
s.send(cmd_release.encode())
# Must wait for Amavisd's response before deleting SQL record,
# otherwise we may delete sql record BEFORE Amavisd releases
# quarantined email.
s.recv(1024)
released_mail_ids += [record.get('mail_id', 'NOT-EXIST')]
except Exception as e:
return False, repr(e)
# Close socket.
try:
s.close()
except Exception as e:
return False, repr(e)
# Return if no record was released successfully.
if len(released_mail_ids) == 0:
return True,
# Update Amavisd SQL database.
try:
# - Update msgs.content to 'C' (Clean)
# UPDATE msgs \
# SET msgs.content = 'C' \
# WHERE msgs.mail_id IN ('xxx', 'yyy', ..)
#
# - Delete records in 'quarantine':
# DELETE FROM quarantine \
# WHERE quarantine.partition_tag = msgs.partition_tag \
# AND quarantine.mail_id = msgs.mail_id
#
web.conn_amavisd.update(
'msgs',
where='mail_id IN ' + web.sqlquote(released_mail_ids),
quar_type='',
content='C',
)
web.conn_amavisd.delete(
'quarantine',
where='mail_id IN ' + web.sqlquote(released_mail_ids),
)
return True,
except Exception as e:
return False, repr(e)

350
libs/amavisd/spampolicy.py Normal file
View File

@@ -0,0 +1,350 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
import settings
from libs import form_utils
from libs.iredutils import is_valid_amavisd_address
from libs.amavisd import utils
session = web.config.get('_session')
DEFAULT_SPAM_TAG_LEVEL = 2
DEFAULT_SPAM_TAG2_LEVEL = 6
# Builtin ban rule names.
BUILTIN_BAN_RULE_NAMES = [
"ALLOW_MS_OFFICE",
"ALLOW_MS_WORD",
"ALLOW_MS_EXCEL",
"ALLOW_MS_PPT",
]
def delete_spam_policy(account):
account = str(account).lower()
if not is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
try:
web.conn_amavisd.delete('policy',
vars={'account': account},
where='policy_name=$account')
return True,
except Exception as e:
return False, repr(e)
def get_spam_policy(account='@.'):
account = str(account).lower()
if not is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
try:
sql_where = 'users.policy_id=policy.id AND users.email=$account'
qr = web.conn_amavisd.select(
['policy', 'users'],
vars={'account': account},
what='policy.*, users.id AS users_id',
where=sql_where,
limit=1,
)
if qr:
policy = qr[0]
return True, policy
else:
return True, {}
except Exception as e:
return False, repr(e)
def get_global_spam_score():
score = DEFAULT_SPAM_TAG2_LEVEL
(success, policy) = get_spam_policy(account='@.')
if success and policy:
score = policy.get('spam_tag2_level', DEFAULT_SPAM_TAG2_LEVEL)
return score
def update_spam_policy(account, form):
account = str(account).lower()
if not is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
if 'delete_policy' in form:
try:
web.conn_amavisd.delete(
'policy',
vars={'account': account},
where='policy_name=$account',
)
return True,
except Exception as e:
return False, repr(e)
qr = utils.get_policy_record(account=account, create_if_missing=True)
if qr[0]:
policy_id = qr[1].id
else:
return qr
# Update spam policy
updates = {
'spam_lover': 'N',
'virus_lover': 'N',
'banned_files_lover': 'N',
'bad_header_lover': 'N',
'bypass_spam_checks': 'N',
'bypass_virus_checks': 'N',
'bypass_banned_checks': 'N',
'bypass_header_checks': 'N',
'banned_rulenames': "",
}
if 'enable_spam_checks' not in form:
updates['bypass_spam_checks'] = 'Y'
if 'enable_virus_checks' not in form:
updates['bypass_virus_checks'] = 'Y'
if 'enable_banned_checks' not in form:
updates['bypass_banned_checks'] = 'Y'
if 'enable_header_checks' not in form:
updates['bypass_header_checks'] = 'Y'
updates['spam_quarantine_to'] = ''
updates['virus_quarantine_to'] = 'virus-quarantine'
updates['banned_quarantine_to'] = ''
updates['bad_header_quarantine_to'] = ''
if 'spam_quarantine_to' in form:
updates['spam_quarantine_to'] = 'spam-quarantine'
# else:
# updates['spam_lover'] = 'Y'
if 'virus_quarantine_to' not in form:
# Deliver virus to mailbox.
updates['virus_lover'] = 'Y'
updates['virus_quarantine_to'] = ''
if 'banned_quarantine_to' in form:
updates['banned_quarantine_to'] = 'banned-quarantine'
# else:
# updates['banned_files_lover'] = 'Y'
if 'bad_header_quarantine_to' in form:
updates['bad_header_quarantine_to'] = 'bad-header-quarantine'
else:
updates['bad_header_lover'] = 'Y'
# Modify spam subject
if 'modify_spam_subject' in form:
updates['spam_subject_tag2'] = settings.AMAVISD_SPAM_SUBJECT_PREFIX
else:
updates['spam_subject_tag2'] = None
updates['spam_tag_level'] = None
updates['spam_tag2_level'] = None
updates['spam_kill_level'] = None
if account == '@.' and 'always_insert_x_spam_headers' in form:
updates['spam_tag_level'] = -100
for p in ['spam_tag2_level', 'spam_kill_level']:
_score = form.get(p, '')
if _score:
try:
updates[p] = float(_score)
except:
pass
if "banned_rulenames" in form:
names = form.get("banned_rulenames", [])
new_names = set()
for n in names:
if (n in BUILTIN_BAN_RULE_NAMES) or (n in settings.AMAVISD_BAN_RULES):
new_names.add(n)
# Sort the result for easier unittest.
new_names = sorted(new_names)
updates["banned_rulenames"] = ",".join(new_names)
try:
web.conn_amavisd.update(
'policy',
vars={'id': policy_id},
where='id=$id',
**updates)
qr = utils.link_policy_to_user(account=account, policy_id=policy_id)
if not qr[0]:
return qr
# Update `policy.spam_tag3_level` and `policy.spam_subject_tag3`
# separately, these two columns don't exist in Amavisd-new-2.6.x.
try:
extra_updates = {'spam_tag3_level': updates['spam_tag2_level'],
'spam_subject_tag3': updates['spam_subject_tag2']}
web.conn_amavisd.update(
'policy',
vars={'id': policy_id},
where='id=$id',
**extra_updates)
except:
pass
return True,
except Exception as e:
return False, repr(e)
def api_update_spam_policy(account, form):
"""Create new spam policy or update existing policy."""
account = str(account).lower()
if not is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
# Get current `amavisd.policy.id`, it will create a new one if not present.
qr = utils.get_policy_record(account=account, create_if_missing=True)
if not qr[0]:
return qr
# Set default policy
policy = {
'policy_name': account,
# Default check policy: don't bypass checks
'bypass_spam_checks': 'N',
'bypass_virus_checks': 'N',
'bypass_banned_checks': 'N',
'bypass_header_checks': 'N',
# Default quarantining policy: quarantine virus
'spam_quarantine_to': None,
'virus_quarantine_to': 'virus-quarantine',
'banned_quarantine_to': None,
'bad_header_quarantine_to': None,
# tags/scores
'spam_subject_tag': None,
'spam_subject_tag2': None,
'spam_tag_level': None,
'spam_kill_level': None,
# ban rules.
"banned_rulenames": "",
}
for k in ['spam', 'virus', 'banned', 'header']:
# Checks: bypass_<k>_checks
_chk = 'bypass_' + k + '_checks'
v = form_utils.get_single_value(form, input_name=_chk, to_string=True)
if v:
if v == 'yes':
v = 'Y' # Exclictly enable
elif v == 'no':
v = 'N' # Exclictly disable
else:
v = None # Don't set a value, use default policy.
policy[_chk] = v
# Quarantining: quarantine_<k>
_quar_input = 'quarantine_' + k
_quar_key = k + '_quarantine_to'
if k == 'header':
_quar_input = 'quarantine_bad_header'
_quar_key = 'bad_header_quarantine_to'
v = form_utils.get_single_value(form=form, input_name=_quar_input, to_string=True)
if v:
if v == 'yes':
v = k + '-quarantine'
if k == 'header':
v = 'bad-header-quarantine'
else:
v = None
policy[_quar_key] = v
# Modify spam subject
v = form_utils.get_single_value(form=form, input_name='prefix_spam_in_subject', to_string=True)
if v:
if v == 'yes':
policy['spam_subject_tag'] = settings.AMAVISD_SPAM_SUBJECT_PREFIX
policy['spam_subject_tag2'] = settings.AMAVISD_SPAM_SUBJECT_PREFIX
else:
policy['spam_subject_tag'] = None
policy['spam_subject_tag2'] = None
v = form_utils.get_single_value(form=form, input_name='always_insert_x_spam_headers', to_string=True)
if v:
if v == 'yes':
policy['spam_tag_level'] = -100
else:
policy['spam_tag_level'] = None
v = form_utils.get_single_value(form=form, input_name='spam_score', to_string=True)
if v.isdigit():
try:
_score = float(v)
policy['spam_tag2_level'] = _score
policy['spam_kill_level'] = _score
except:
return False, 'INVALID_SPAM_SCORE'
# Get ban rules.
names = form_utils.get_multi_values_from_api(form,
input_name="banned_rulenames",
to_string=True,
to_lowercase=False)
if names:
new_names = set()
for n in names:
if (n in BUILTIN_BAN_RULE_NAMES) or (n in settings.AMAVISD_BAN_RULES):
new_names.add(n)
policy["banned_rulenames"] = ",".join(new_names)
qr = delete_spam_policy(account=account)
if not qr[0]:
return qr
# column `users_id` is not a column name in `amavisd.policy` table,
# it's set by SQL statement `LEFT JOIN`.
if 'users_id' in policy:
policy.pop('users_id')
try:
policy_id = web.conn_amavisd.insert('policy', **policy)
qr = utils.link_policy_to_user(account=account, policy_id=policy_id)
if not qr[0]:
return qr
# Update `policy.spam_tag3_level` and `policy.spam_subject_tag3`
# separately, these two columns don't exist in Amavisd-new-2.6.x.
try:
extra_updates = {'spam_tag3_level': policy['spam_tag2_level'],
'spam_subject_tag3': policy['spam_subject_tag2']}
web.conn_amavisd.update(
'policy',
vars={'id': policy_id},
where='id=$id',
**extra_updates)
except:
pass
return True,
except Exception as e:
return False, repr(e)

207
libs/amavisd/utils.py Normal file
View File

@@ -0,0 +1,207 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
import settings
from libs import iredutils
from libs.iredutils import is_valid_amavisd_address
def create_mailaddr(addresses):
for addr in addresses:
addr_type = iredutils.is_valid_amavisd_address(addr)
if addr_type in iredutils.MAILADDR_PRIORITIES:
try:
web.conn_amavisd.insert(
'mailaddr',
priority=iredutils.MAILADDR_PRIORITIES[addr_type],
email=addr,
)
except:
pass
return True
def create_user(account, return_record=True):
# Create a new record in `amavisd.users`
addr_type = is_valid_amavisd_address(account)
try:
# Use policy_id=0 to make sure it's not linked to any policy.
web.conn_amavisd.insert(
'users',
policy_id=0,
email=account,
priority=iredutils.MAILADDR_PRIORITIES[addr_type],
)
if return_record:
qr = web.conn_amavisd.select(
'users',
vars={'account': account},
what='*',
where='email=$account',
limit=1,
)
return True, qr[0]
else:
return True,
except Exception as e:
return False, repr(e)
def get_user_record(account, create_if_missing=True):
try:
qr = web.conn_amavisd.select(
'users',
vars={'email': account},
what='*',
where='email=$email',
limit=1,
)
if qr:
return True, qr[0]
else:
if create_if_missing:
qr = create_user(account=account, return_record=True)
if qr[0]:
return True, qr[1]
else:
return qr
else:
return False, 'ACCOUNT_NOT_EXIST'
except Exception as e:
return False, repr(e)
def create_policy(account, return_record=True):
# Create a new record in `amavisd.policy`
try:
values = {
'policy_name': account,
'spam_quarantine_to': 'spam-quarantine',
'virus_quarantine_to': 'virus-quarantine',
'spam_subject_tag2': settings.AMAVISD_SPAM_SUBJECT_PREFIX,
}
web.conn_amavisd.insert('policy', **values)
# Update `policy.spam_tag3_level` and `policy.spam_subject_tag3`
# separately, these two columns don't exist in Amavisd-new-2.6.x.
try:
extra_values = {'spam_subject_tag3': settings.AMAVISD_SPAM_SUBJECT_PREFIX}
web.conn_amavisd.update(
'policy',
vars={'policy_name': account},
where='policy_name=$policy_name',
**extra_values)
except:
pass
if return_record:
qr = web.conn_amavisd.select(
'policy',
vars={'account': account},
what='*',
where='policy_name=$account',
limit=1,
)
return True, qr[0]
else:
return True,
except Exception as e:
return False, repr(e)
def get_policy_record(account, create_if_missing=False):
try:
qr = web.conn_amavisd.select(
'policy',
vars={'account': account},
what='id',
where='policy_name=$account',
limit=1,
)
if qr:
return True, qr[0]
else:
if create_if_missing:
qr = create_policy(account=account, return_record=True)
if qr[0]:
return True, qr[1]
else:
return qr
else:
return True, {}
except Exception as e:
return False, repr(e)
def link_policy_to_user(account, policy_id):
qr = get_user_record(account)
if qr[0]:
user_id = qr[1].id
else:
return qr
try:
web.conn_amavisd.update(
'users',
vars={'id': user_id},
policy_id=policy_id,
where='id=$id',
)
return True,
except Exception as e:
return False, repr(e)
def delete_policy_accounts(accounts):
sqlvars = {'accounts': accounts}
try:
# Get mailaddr.id of accounts
qr = web.conn_amavisd.select(
'users',
vars=sqlvars,
what='id',
where='email IN $accounts',
)
ids = []
for i in qr:
ids.append(i.id)
# Delete wblist
web.conn_amavisd.delete(
'wblist',
vars={'ids': ids},
where='rid IN $ids',
)
# Delete outbound wblist
web.conn_amavisd.delete(
'outbound_wblist',
vars={'ids': ids},
where='sid IN $ids',
)
# Delete policy
web.conn_amavisd.delete(
'policy',
vars=sqlvars,
where='policy_name IN $accounts',
)
# Delete users
web.conn_amavisd.delete(
'users',
vars=sqlvars,
where='email IN $accounts',
)
except Exception as e:
return False, repr(e)
return True,

454
libs/amavisd/wblist.py Normal file
View File

@@ -0,0 +1,454 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
from libs import iredutils
from libs.logger import log_activity
from libs.amavisd import utils
session = web.config.get('_session')
def get_wblist(account,
whitelist=True,
blacklist=True,
outbound_whitelist=True,
outbound_blacklist=True):
"""Get white/blacklists of specified account."""
inbound_sql_where = 'users.email=$user AND users.id=wblist.rid AND wblist.sid = mailaddr.id'
if whitelist and not blacklist:
inbound_sql_where += ' AND wblist.wb=%s' % web.sqlquote('W')
if not whitelist and blacklist:
inbound_sql_where += ' AND wblist.wb=%s' % web.sqlquote('B')
outbound_sql_where = 'users.email=$user AND users.id=outbound_wblist.sid AND outbound_wblist.rid = mailaddr.id'
if outbound_whitelist and not outbound_blacklist:
outbound_sql_where += ' AND outbound_wblist.wb=%s' % web.sqlquote('W')
if not whitelist and blacklist:
outbound_sql_where += ' AND outbound_wblist.wb=%s' % web.sqlquote('B')
wl = []
bl = []
outbound_wl = []
outbound_bl = []
try:
qr = web.conn_amavisd.select(
['mailaddr', 'users', 'wblist'],
vars={'user': account},
what='mailaddr.email AS address, wblist.wb AS wb',
where=inbound_sql_where,
)
for r in qr:
if r.wb == 'W':
wl.append(iredutils.bytes2str(r.address))
else:
bl.append(iredutils.bytes2str(r.address))
qr = web.conn_amavisd.select(
['mailaddr', 'users', 'outbound_wblist'],
vars={'user': account},
what='mailaddr.email AS address, outbound_wblist.wb AS wb',
where=outbound_sql_where,
)
for r in qr:
if r.wb == 'W':
outbound_wl.append(iredutils.bytes2str(r.address))
else:
outbound_bl.append(iredutils.bytes2str(r.address))
except Exception as e:
return False, e
wl.sort()
bl.sort()
outbound_wl.sort()
outbound_bl.sort()
return (True, {'inbound_whitelists': wl,
'inbound_blacklists': bl,
'outbound_whitelists': outbound_wl,
'outbound_blacklists': outbound_bl})
def add_wblist(account,
wl_senders=None,
bl_senders=None,
wl_rcpts=None,
bl_rcpts=None,
flush_before_import=False):
"""Add white/blacklists for specified account.
wl_senders -- whitelist senders (inbound)
bl_senders -- blacklist senders (inbound)
wl_rcpts -- whitelist recipients (outbound)
bl_rcpts -- blacklist recipients (outbound)
flush_before_import -- Delete all existing wblist before importing
new wblist
"""
if not iredutils.is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
# Remove duplicate.
if wl_senders:
wl_senders = {str(s).lower()
for s in wl_senders
if iredutils.is_valid_wblist_address(s)}
else:
wl_senders = []
# Whitelist has higher priority, don't include whitelisted sender.
if bl_senders:
bl_senders = {str(s).lower()
for s in bl_senders
if iredutils.is_valid_wblist_address(s)}
else:
bl_senders = []
if wl_rcpts:
wl_rcpts = {str(s).lower()
for s in wl_rcpts
if iredutils.is_valid_wblist_address(s)}
else:
wl_rcpts = []
if bl_rcpts:
bl_rcpts = {str(s).lower()
for s in bl_rcpts
if iredutils.is_valid_wblist_address(s)}
else:
bl_rcpts = []
if flush_before_import:
if wl_senders:
bl_senders = {s for s in bl_senders if s not in wl_senders}
if wl_rcpts:
bl_rcpts = {s for s in bl_rcpts if s not in wl_rcpts}
sender_addresses = set(wl_senders) | set(bl_senders)
rcpt_addresses = set(wl_rcpts) | set(bl_rcpts)
all_addresses = list(sender_addresses | rcpt_addresses)
# Get current user's id from `amavisd.users`
qr = utils.get_user_record(account=account)
if qr[0]:
user_id = qr[1].id
else:
return qr
# Delete old records
if flush_before_import:
# user_id = wblist.rid
web.conn_amavisd.delete(
'wblist',
vars={'rid': user_id},
where='rid=$rid',
)
# user_id = outbound_wblist.sid
web.conn_amavisd.delete(
'outbound_wblist',
vars={'sid': user_id},
where='sid=$sid',
)
if not all_addresses:
return True,
# Insert all senders into `amavisd.mailaddr`
utils.create_mailaddr(addresses=all_addresses)
# Get `mailaddr.id` of senders
sender_records = {}
if sender_addresses:
qr = web.conn_amavisd.select(
'mailaddr',
vars={'addresses': list(sender_addresses)},
what='id, email',
where='email IN $addresses',
)
for r in qr:
sender_records[iredutils.bytes2str(r.email)] = r.id
del qr
# Get `mailaddr.id` of recipients
rcpt_records = {}
if rcpt_addresses:
qr = web.conn_amavisd.select(
'mailaddr',
vars={'addresses': list(rcpt_addresses)},
what='id, email',
where='email IN $addresses',
)
for r in qr:
rcpt_records[iredutils.bytes2str(r.email)] = r.id
del qr
# Remove existing records of current submitted records before inserting new.
try:
if sender_records:
web.conn_amavisd.delete(
'wblist',
vars={'rid': user_id, 'sid': list(sender_records.values())},
where='rid=$rid AND sid IN $sid',
)
if rcpt_records:
web.conn_amavisd.delete(
'outbound_wblist',
vars={'sid': user_id, 'rid': list(rcpt_records.values())},
where='sid=$sid AND rid IN $rid',
)
except Exception as e:
return False, repr(e)
# Generate dict used to build SQL statements for importing wblist
values = []
if sender_addresses:
for s in wl_senders:
if sender_records.get(s):
values.append({'rid': user_id, 'sid': sender_records[s], 'wb': 'W'})
for s in bl_senders:
# Filter out same record in blacklist
if sender_records.get(s) and s not in wl_senders:
values.append({'rid': user_id, 'sid': sender_records[s], 'wb': 'B'})
rcpt_values = []
if rcpt_addresses:
for s in wl_rcpts:
if rcpt_records.get(s):
rcpt_values.append({'sid': user_id, 'rid': rcpt_records[s], 'wb': 'W'})
for s in bl_rcpts:
# Filter out same record in blacklist
if rcpt_records.get(s) and s not in wl_rcpts:
rcpt_values.append({'sid': user_id, 'rid': rcpt_records[s], 'wb': 'B'})
try:
if values:
web.conn_amavisd.multiple_insert('wblist', values)
if rcpt_values:
web.conn_amavisd.multiple_insert('outbound_wblist', rcpt_values)
# Log
if values:
if flush_before_import:
log_activity(msg='Update whitelists and/or blacklists for %s.' % account,
admin=session['username'],
event='update_wblist')
else:
if wl_senders:
log_activity(msg='Add whitelists for {}: {}.'.format(account, ', '.join(wl_senders)),
admin=session['username'],
event='update_wblist')
if bl_senders:
log_activity(msg='Add blacklists for {}: {}.'.format(account, ', '.join(bl_senders)),
admin=session['username'],
event='update_wblist')
if rcpt_values:
if flush_before_import:
log_activity(msg='Update outbound whitelists and/or blacklists for %s.' % account,
admin=session['username'],
event='update_wblist')
else:
if wl_rcpts:
log_activity(msg='Add outbound whitelists for {}: {}.'.format(account, ', '.join(wl_senders)),
admin=session['username'],
event='update_wblist')
if bl_rcpts:
log_activity(msg='Add outbound blacklists for {}: {}.'.format(account, ', '.join(bl_senders)),
admin=session['username'],
event='update_wblist')
except Exception as e:
return False, repr(e)
return True,
def delete_wblist(account,
wl_senders=None,
bl_senders=None,
wl_rcpts=None,
bl_rcpts=None):
if not iredutils.is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
# Remove duplicate.
if wl_senders:
wl_senders = list({str(s).lower()
for s in wl_senders
if iredutils.is_valid_wblist_address(s)})
# Whitelist has higher priority, don't include whitelisted sender.
if bl_senders:
bl_senders = list({str(s).lower()
for s in bl_senders
if iredutils.is_valid_wblist_address(s)})
if wl_rcpts:
wl_rcpts = list({str(s).lower()
for s in wl_rcpts
if iredutils.is_valid_wblist_address(s)})
if bl_rcpts:
bl_rcpts = list({str(s).lower()
for s in bl_rcpts
if iredutils.is_valid_wblist_address(s)})
# Get account id from `amavisd.users`
qr = utils.get_user_record(account=account)
if qr[0]:
user_id = qr[1].id
else:
return qr
# Remove wblist.
# No need to remove unused senders in `mailaddr` table, because we
# have daily cron job to delete them (tools/cleanup_amavisd_db.py).
try:
# Get `mailaddr.id` for wblist senders
if wl_senders:
sids = []
qr = web.conn_amavisd.select(
'mailaddr',
vars={'addresses': wl_senders},
what='id',
where='email IN $addresses',
)
for r in qr:
sids.append(r.id)
if sids:
web.conn_amavisd.delete(
'wblist',
vars={'user_id': user_id, 'sids': sids},
where="rid=$user_id AND sid IN $sids AND wb='W'",
)
if bl_senders:
sids = []
qr = web.conn_amavisd.select(
'mailaddr',
vars={'addresses': bl_senders},
what='id',
where='email IN $addresses',
)
for r in qr:
sids.append(r.id)
if sids:
web.conn_amavisd.delete(
'wblist',
vars={'user_id': user_id, 'sids': sids},
where="rid=$user_id AND sid IN $sids AND wb='B'",
)
if wl_rcpts:
rids = []
qr = web.conn_amavisd.select(
'mailaddr',
vars={'addresses': wl_rcpts},
what='id',
where='email IN $addresses',
)
for r in qr:
rids.append(r.id)
if rids:
web.conn_amavisd.delete(
'outbound_wblist',
vars={'user_id': user_id, 'rids': rids},
where="sid=$user_id AND rid IN $rids AND wb='W'",
)
if bl_rcpts:
rids = []
qr = web.conn_amavisd.select(
'mailaddr',
vars={'addresses': bl_rcpts},
what='id',
where='email IN $addresses',
)
for r in qr:
rids.append(r.id)
if rids:
web.conn_amavisd.delete(
'outbound_wblist',
vars={'user_id': user_id, 'rids': rids},
where="sid=$user_id AND rid IN $rids AND wb='B'",
)
except Exception as e:
return False, repr(e)
return True,
def delete_all_wblist(account,
wl_senders=False,
bl_senders=False,
wl_rcpts=False,
bl_rcpts=False):
if not iredutils.is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
# Get account id from `amavisd.users`
qr = utils.get_user_record(account=account)
if qr[0]:
user_id = qr[1].id
else:
return qr
# Remove ALL wblist.
# No need to remove unused senders in `mailaddr` table, because we
# have daily cron job to delete them (tools/cleanup_amavisd_db.py).
try:
if wl_senders:
web.conn_amavisd.delete(
'wblist',
vars={'user_id': user_id},
where="rid=$user_id AND wb='W'",
)
if bl_senders:
web.conn_amavisd.delete(
'wblist',
vars={'user_id': user_id},
where="rid=$user_id AND wb='B'",
)
if wl_rcpts:
web.conn_amavisd.delete(
'outbound_wblist',
vars={'user_id': user_id},
where="sid=$user_id AND wb='W'",
)
if bl_rcpts:
web.conn_amavisd.delete(
'outbound_wblist',
vars={'user_id': user_id},
where="sid=$user_id AND wb='B'",
)
except Exception as e:
return False, repr(e)
return True,

686
libs/default_settings.py Normal file
View File

@@ -0,0 +1,686 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
# --------------------------------------
# WARNING
# --------------------------------------
# Please place all your custom settings in settings.py to override settings
# listed in this file, so that you can simply copy settings.py while upgrading
# iRedAdmin.
# --------------------------------------
# Debug iRedAdmin: True, False.
DEBUG = False
# Session timeout in seconds. Default is 30 minutes (1800 seconds).
SESSION_TIMEOUT = 1800
# if set to False, session will expire when client ip was changed.
SESSION_IGNORE_CHANGE_IP = False
# Mail detail message of '500 internal server error' to webmaster: True, False.
# If set to True, iredadmin will mail detail error to webmaster when
# it catches 'internal server error' via LOCAL mail server to aid
# in debugging production servers.
MAIL_ERROR_TO_WEBMASTER = False
#
# Logging
#
# Log target: syslog, stdout.
# If set to `syslog`, parameters start with `SYSLOG_` below are required.
LOG_TARGET = "syslog"
# Log level. Used by all logging handlers.
LOG_LEVEL = "info"
#
# Syslog
#
# Syslog server address. Log to local syslog socket by default.
# Syslog socket path:
# - /dev/log on Linux/OpenBSD
# - /var/run/log on FreeBSD.
# Some distro running systemd may have incorrect permission on /dev/log, it's
# ok to use alternative syslog socket /run/systemd/journal/syslog instead.
SYSLOG_SERVER = "/dev/log"
SYSLOG_PORT = 514
# Syslog facility
SYSLOG_FACILITY = "local5"
# Log programming error in SQL database, and viewed in `System -> Admin Log`.
# This should be used only in testing server, not on production server, because
# the error message may contain sensitive information.
LOG_PROGRAMMING_ERROR_IN_SQL = False
# Skin/theme. iRedAdmin will use CSS files and HTML templates under
# - statics/{skin}/
# - templates/{skin}/
SKIN = "default"
# Set http proxy server address if iRedAdmin cannot access internet
# (iredmail.org) directly.
# Sample:
# - Without authentication: HTTP_PROXY = "http://192.168.1.1:3128"
# - With authentication: HTTP_PROXY = "http://user:password@192.168.1.1:3128"
HTTP_PROXY = ""
# Local timezone. It must be one of below:
# GMT-12:00
# GMT-11:00
# GMT-10:00
# GMT-09:30
# GMT-09:00
# GMT-08:00
# GMT-07:00
# GMT-06:00
# GMT-05:00
# GMT-04:30
# GMT-04:00
# GMT-03:30
# GMT-03:00
# GMT-02:00
# GMT-01:00
# GMT
# GMT+01:00
# GMT+02:00
# GMT+03:00
# GMT+03:30
# GMT+04:00
# GMT+04:30
# GMT+05:00
# GMT+05:30
# GMT+05:45
# GMT+06:00
# GMT+06:30
# GMT+07:00
# GMT+08:00
# GMT+08:45
# GMT+09:00
# GMT+09:30
# GMT+10:00
# GMT+10:30
# GMT+11:00
# GMT+11:30
# GMT+12:00
# GMT+12:45
# GMT+13:00
# GMT+14:00
LOCAL_TIMEZONE = "GMT"
###################################
# RESTful API
#
# Enable RESTful API
ENABLE_RESTFUL_API = False
# Restrict API access to specified IP addresses or networks.
# if not allowed, client will receive error message 'NOT_AUTHORIZED'
RESTFUL_API_CLIENTS = []
# For standalone admin account.
#
# Hide SQL columns (for SQL editions) or LDAP attributes (for LDAP backends)
# in admin or user profiles.
# If you need to verify admin password, use API endpoint
# '/api/verify_password/admin/<mail>' instead.
API_HIDDEN_ADMIN_PROFILES = ["password", "userPassword"]
API_HIDDEN_USER_PROFILES = ["password", "userPassword"]
###################################
# Domwin ownership verification
#
# Require domain ownership verification if it's added by normal domain admin:
# True, False.
REQUIRE_DOMAIN_OWNERSHIP_VERIFICATION = True
# How long should we remove verified or (inactive) unverified domain ownerships.
#
# iRedAdmin-Pro stores verified ownership in SQL database, if (same) admin
# removed the domain and re-adds it, no verification required.
#
# Usually normal domain admin won't frequently remove and re-add same domain
# name, so it's ok to remove saved ownership after X days.
DOMAIN_OWNERSHIP_EXPIRE_DAYS = 30
# The string prefixed to verify code. Must be shorter than than 60 characters.
DOMAIN_OWNERSHIP_VERIFY_CODE_PREFIX = "iredmail-domain-verification-"
# Timeout (in seconds) while performing each verification.
DOMAIN_OWNERSHIP_VERIFY_TIMEOUT = 10
###################################
# General settings
#
# Show percentage of mailbox quota usage. Require parameter SQL_TBL_USED_QUOTA.
SHOW_USED_QUOTA = True
# SQL table used to store real-time mailbox quota usage.
# - For SQL backends, it's stored in SQL db 'vmail'.
# - For LDAP backend, it's stored in SQL db 'iredadmin'.
SQL_TBL_USED_QUOTA = "used_quota"
# Default password scheme, must be a string.
# Passwords of new mail accounts will be encrypted by specified scheme.
#
# - LDAP backends: BCRYPT, SSHA512, SSHA, PLAIN.
# Multiple passwords are supported if you separate schemes
# with '+'. For example:
# 'SSHA+MD5', 'CRAM-MD5+SSHA', 'CRAM-MD5+SSHA+MD5'.
#
# - SQL backends: BCRYPT, SSHA512, SSHA, MD5, PLAIN-MD5 (without salt), PLAIN.
# Multiple passwords are NOT supported.
#
# Recommended schemes in specified order:
#
# BCRYPT -> SSHA512 -> SSHA.
#
# WARNING: MD5, PLAIN-MD5, PLAIN are not recommended.
#
# Important notes:
#
# - Password length and complexity are probably more important then a strong
# crypt algorithm.
#
# - You can get available algorithms with command `doveadm pw -l`
# ('BLF-CRYPT' is BCRYPT).
#
# - BCRYPT: *) must be supported by libc on your system.
# FreeBSD and OpenBSD support it, but most latest Linux
# distributions not yet support it.
# Since Dovecot-2.3.0, BCRYPT is provided by dovecot.
#
# *) BCRYPT is slower than SSHA512, SSHA, MD5.
# But, "Speed is exactly what you don't want in a password hash
# function."
#
# *) References:
# - A Future-Adaptable Password Scheme:
# http://www.openbsd.org/papers/bcrypt-paper.ps
# - How to safely store a password:
# http://codahale.com/how-to-safely-store-a-password/
#
# - SSHA512: requires Dovecot-2.0 (or later) and Python-2.5 (or later).
# If you're running Python-2.4, iRedAdmin will generate SSHA hash
# instead of SSHA512. But if you're running Dovecot-1.x, user
# authentication will fail.
#
# OpenLDAP doesn't support user authentication with SSHA512
# directly, so you must set 'auth_bind = no' in
# /etc/dovecot/dovecot-ldap.conf to let Dovecot do the password
# verification instead.
#
# Sample password format:
#
# - BCRYPT: {CRYPT}$2a$05$TKnXV39M3uJ4o.AbY1HbjeAval9bunHbxd0.6Qn782yKoBjTEBXTe
# NOTE: Use prefix '{CRYPT}' instead of '{BLF-CRYPT}'.
# - SSHA512: {SSHA512}FxgXDhBVYmTqoboW+ibyyzPv/wGG7y4VJtuHWrx+wfqrs/lIH2Qxn2eA0jygXtBhMvRi7GNFmL++6aAZ0kXpcy1fxag=
# - SSHA: {SSHA}bfxqKqOOKODJw/bGqMo54f9Q/iOvQoftOQrqWA==
# - CRAM-MD5: {CRAM-MD5}465076e1c95ac134fc2ba88ad617b6660958f388d60423504ee7c46ce44be8b4
# - MD5: $1$ozdpg0V0$0fb643pVsPtHVPX8mCZYW/
# - PLAIN-MD5: 900150983cd24fb0d6963f7d28e17f72.
# - PLAIN: Plain text.
#
# References:
#
# - Dovecot password schemes:
# https://wiki.dovecot.org/Authentication/PasswordSchemes
#
#
DEFAULT_PASSWORD_SCHEME = "SSHA"
# List of password schemes which should not prefix scheme name in generated hash.
# Currently, only this setting impacts NTLM only.
# Sample setting:
#
# HASHES_WITHOUT_PREFIXED_PASSWORD_SCHEME = ['NTLM']
#
# Sample password hashes:
#
# NTLM without prefix: {NTLM}32ED87BDB5FDC5E9CBA88547376818D4
# NTLM without prefix: 32ED87BDB5FDC5E9CBA88547376818D4
HASHES_WITHOUT_PREFIXED_PASSWORD_SCHEME = ["NTLM"]
# Allow to store password in plain text.
# It will show a HTML checkbox to allow admin to store newly created user
# password or reset password in plain text. If not checked, password
# will be stored as encrypted.
# See DEFAULT_PASSWORD_SCHEME below.
STORE_PASSWORD_IN_PLAIN_TEXT = False
# Always store plain password in additional LDAP attribute of user object, or
# SQL column (in `vmail.mailbox` table).
# Value must be a valid LDAP attribute name of user object, or SQL column name
# in `vmail.mailbox` table.
STORE_PLAIN_PASSWORD_IN_ADDITIONAL_ATTR = ""
# Set password last change date for newly created user. Defaults to True.
# If you want to force end user to change password when first login or send
# first email (with iRedAPD plugin `*_force_change_password`), please set it to
# False.
SET_PASSWORD_CHANGE_DATE_FOR_NEW_USER = True
#
# Password restrictions
#
# Special characters which can be used in password.
# Notes: iOS devices may have problem with character '^'.
PASSWORD_SPECIAL_CHARACTERS = """#$%&*+-,.:;!=<>'"?@[]/(){}_`~"""
# Must contain at least one letter, one uppercase letter, one number, one special character
PASSWORD_HAS_LETTER = True
PASSWORD_HAS_UPPERCASE = True
PASSWORD_HAS_NUMBER = True
PASSWORD_HAS_SPECIAL_CHAR = True
# Log PERMISSION_DENIED operations to stdout or web server log file.
LOG_PERMISSION_DENIED = True
# Redirect to "Domains and Accounts" page instead of Dashboard.
REDIRECT_TO_DOMAIN_LIST_AFTER_LOGIN = False
# List of IP addresses/networks which global admins are allowed to login from.
# Valid formats:
# - Single IP address: 192.168.1.1
# - IPv4/IPv6 network: 192.168.1.0/24
GLOBAL_ADMIN_IP_LIST = []
# List of IP addresses/networks which (both global and normal) admins are
# allowed to login from.
# Valid formats:
# - Single IP address: 192.168.1.1
# - IPv4/IPv6 network: 192.168.1.0/24
ADMIN_LOGIN_IP_LIST = []
# List all local transports.
LOCAL_TRANSPORTS = [
"dovecot",
"lmtp:unix:private/dovecot-lmtp",
"lmtp:inet:127.0.0.1:24",
]
# Redirect to which page after logged in.
# Available values are: preferences, quarantined, received, wblist, spampolicy.
SELF_SERVICE_DEFAULT_PAGE = "preferences"
###################################
# Maildir related.
#
# It's RECOMMEND for better performance. Samples:
# - hashed: domain.com/u/s/e/username-2009.09.04.12.05.33/
# - non-hashed: domain.com/username-2009.09.04.12.05.33/
MAILDIR_HASHED = True
# Prepend domain name in path. Samples:
# - with domain name: domain.com/username/
# - without: username/
MAILDIR_PREPEND_DOMAIN = True
# Append timestamp in path. Samples:
# - with timestamp: domain.com/username-2010.12.20.13.13.33/
# - without timestamp: domain.com/username/
MAILDIR_APPEND_TIMESTAMP = True
# Mailbox format (in lower cases)
#
# Any mailbox formats supported by Dovecot can be used here, e.g. maildir,
# mdbox. For more details please visit Dovecot website:
# https://wiki.dovecot.org/MailboxFormat
MAILBOX_FORMAT = "maildir"
# Default folder used to store mailbox under per-user HOME directory.
#
# - Folder name is case SeNsItIvE. Defaults to 'Maildir'.
#
# - If not set, Dovecot will use the hard-coded setting defined in its config
# file.
#
# - It will be appended to the `mail` variable returned by Dovecot SQL/LDAP
# query. for example, sql query in `/etc/dovecot/dovecot-mysql.conf`:
#
# user_query = SELECT ..., CONCAT(...) AS mail, ...
#
# Or LDAP query in `/etc/dovecot/dovecot-ldap.conf`:
#
# user_attrs = ...,=mail=%{ldap:mailboxFormat:maildir}:~/%{ldap:mailboxFolder:Maildir}/,...
MAILBOX_FOLDER = "Maildir"
# How many days the normal domain admin can choose to keep the mailbox after
# account removal.
# To make it simpler, we use 30 days for one month, 365 days for one year.
DAYS_TO_KEEP_REMOVED_MAILBOX = [1, 7, 14, 21, 30, 60, 90, 180, 365]
# How many days the global domain admin can choose to keep the mailbox after
# account removal.
# To make it simpler, we use 30 days for one month, 365 days for one year.
# 0 means keeping forever.
DAYS_TO_KEEP_REMOVED_MAILBOX_FOR_GLOBAL_ADMIN = [
0,
1,
7,
14,
21,
30,
60,
90,
180,
365,
730,
1095,
]
#######################################
# LDAP backends related settings.
#
# Define LDAP server product name: OPENLDAP, LDAPD (OpenBSD built-in ldap daemon)
LDAP_SERVER_PRODUCT_NAME = "OPENLDAP"
# LDAP connection trace level. Must be an integer.
LDAP_CONN_TRACE_LEVEL = 0
# Add full dn of (internal) members to mailing list account.
LDAP_ADD_MEMBER_DN_TO_GROUP = True
LDAP_ATTR_MEMBER = "member"
# Additional LDAP attribute names of user object you want to manage.
# Format:
#
# {'attribute_name': {'desc': 'A short description of this attribute',
# 'allowed_domains': [...],
# 'properties': [...]}}
# 'attribute_name2': {...}}
#
# Arguments
# ----------
#
# desc: string. [optional]
# a short description of this attribute.
# If not present, defaults to show attribute name.
#
# allowed_domains: list. [optional]
# a list of domain names which are allowed to use this attribute.
# if not present, defaults to allow all domains to use the attribute.
#
# properties: list. [optional]
# a list of pre-defined property names (string).
# If not present, defaults to ['string'].
#
# Properties
# ----------
#
# - 'require_global_admin': attribute is only managed by global domain admin.
# - 'multivalue': indicates attribute may contain multiple values.
# If not present, defaults to single value.
#
# - 'string': indicates attribute value is short text. will be displayed as
# HTML tag "<input type='text'>".
# - 'text': indicates attribute value is long text. will be displayed as HTML
# "<textarea>".
#
# Warning: 'string', 'text', 'integer' cannot be used at the same time for same
# attribute.
#
# Sample settings:
#
# {'carLicense': {}} # The minimalist setting, just attribute name.
#
# {'carLicense': {'desc': 'Car License',
# 'properties': ['string'],
# 'allowed_domains': ['example.com', 'test.com']}}
ADDITIONAL_MANAGED_USER_ATTRIBUTES = {}
# Additional LDAP objectClass for NEWLY created mail user.
# Sample value: ['inetOrgPerson', 'pwdPolicy', 'ownCloud']
ADDITIONAL_USER_OBJECTCLASSES = []
# Additional LDAP attribute names and values for NEWLY created mail user.
#
# Format:
# [(attribute_name, [...]),
# (attribute_name, [...])]
#
# Several placeholders are available:
# - %(mail)s: mail address of new user
# - %(domain)s: domain part of new user mail address
# - %(username)s: username part of new user mail address
# - %(cn)s: display name of new user
# - %(plain_password)s: new user's plain password
# - %(passwd)s: new user's encrypted password
# - %(quota)d: mailbox quota
# - %(sgroups)s: a list of assigned mailing lists
# - %(storageBaseDirectory)s: path of base storage
# - %(language)s: default language for web UI
# - %(recipient_bcc)s: recipient bcc email address
# - %(sender_bcc)s: sender bcc email address
# - %(next_uid)d: a server-wide free and unique integer for attr `uidNumber`
# - %(next_gid)d: a server-wide free and unique integer for attr `gidNumber`
# - %(shadowLastChange)d: number of days since 1970-01-01, defaults to today.
# - %(shadowLastChange)d+Xd: number of days since 1970-01-01, plus X days (+Xd).
#
# Sample:
#
# ADDITIONAL_USER_ATTRIBUTES = [('uidNumber', ['%(next_uid)d']),
# ('gidNumber', ['%(next_gid)d'])]
ADDITIONAL_USER_ATTRIBUTES = []
# Additional enabled/disabled services for newly created accounts.
#
# - both ADDITIONAL_ENABLED_[XX]_SERVICES, ADDITIONAL_DISABLED_[XX]_SERVICES
# are manageable in account (user/domain) profile page.
#
# - ADDITIONAL_ENABLED_<X>_SERVICES will be added for newly created account
# automatically.
#
# NOTE: This variable is not used by SQL backends, because all services
# are enabled by default.
#
# - ADDITIONAL_DISABLED_<X>_SERVICES will not be added for newly created
# account, admin must go to account profile page to enable them for certain
# accounts.
#
# Notes:
#
# *) for LDAP backends, the service names are assigned to attribute
# `enabledService`. You're free to use custom words for them, for example,
# if you want to limit vpn access for certain users, feel free to use
# `enabledService=vpn` for this purpose.
#
# *) For SQL backends:
#
# Available enabled/disabled services are:
#
# smtp
# smtpsecured
# pop3
# pop3secured
# imap
# imapsecured
# deliver
# managesieve
# managesievesecured
# sogo
# sogowebmail
# sogocalendar
# sogoactivesync
#
# They're mapped to SQL column name in `vmail.mailbox` table with prefix
# string 'enable'. e.g. 'smtp' is mapped to 'enablesmtp' column.
#
ADDITIONAL_ENABLED_DOMAIN_SERVICES = []
ADDITIONAL_DISABLED_DOMAIN_SERVICES = []
# Additional services for mail user.
ADDITIONAL_ENABLED_USER_SERVICES = []
ADDITIONAL_DISABLED_USER_SERVICES = []
#######################################
# MySQL/PostgreSQL backends related settings.
#
# Allow to assign per-user alias address under different domains.
USER_ALIAS_CROSS_ALL_DOMAINS = False
# List all global admins while listing per-domain admins.
# URL: https://<server>/iredadmin/admins/<domain>
SHOW_GLOBAL_ADMINS_IN_PER_DOMAIN_ADMIN_LIST = False
###################################
# iRedAPD related settings.
#
# Query insecure outbound session in latest hours.
IREDAPD_QUERY_INSECURE_OUTBOUND_IN_HOURS = 24
###################################
# Amavisd related settings.
#
# If Amavisd is not running on the database server (settings.amavisd_db_host),
# you should specify the amavisd server address here.
AMAVISD_QUARANTINE_HOST = ""
# Remove old SQL records of sent/received mails in Amavisd database.
# NOTE: require cron job with script tools/cleanup_amavisd_db.py.
AMAVISD_REMOVE_MAILLOG_IN_DAYS = 3
# Remove old SQL records of quarantined mails.
# Since quarantined mails may take much disk space, it's better to release
# or remove them as soon as possible.
# NOTE: require cron job with script tools/cleanup_amavisd_db.py.
AMAVISD_REMOVE_QUARANTINED_IN_DAYS = 7
# Prefix text to the subject of spam
AMAVISD_SPAM_SUBJECT_PREFIX = "[SPAM] "
# If set to true, non-local mail domains/users will appear in mail logs and
# 'Top Senders', 'Top Recipients' too.
AMAVISD_SHOW_NON_LOCAL_DOMAINS = False
# Query size limit. Used by tools/cleanup_amavisd_db.py.
#
# If server is busy and Amavisd generates many records in a short time,
# cleanup script will cause table lock while updating sql tables, and this
# may cause other sql connections which operating on `amavisd` database
# hang/timeout. in this case, you'd better set this parameter to a low
# value to release the table lock sooner. e.g. 10.
AMAVISD_CLEANUP_QUERY_SIZE_LIMIT = 100
# Additional Amavisd ban rules.
# iRedMail has 4 builtin ban rules since iRedMail-1.4.1:
# - ALLOW_MS_OFFICE: Allow all Microsoft Office documents.
# - ALLOW_MS_WORD: Allow Microsoft Word documents (.doc, .docx).
# - ALLOW_MS_EXCEL: Allow Microsoft Excel documents (.xls, .xlsx).
# - ALLOW_MS_PPT: Allow Microsoft PowerPoint documents (.ppt, .pptx).
# You can add your custom ban rules here. Format is:
# {"<rule_name>": "<comment>"}
AMAVISD_BAN_RULES = {}
# Show how many top senders/recipients on Dashboard page.
NUM_TOP_SENDERS = 10
NUM_TOP_RECIPIENTS = 10
# Query statistics for last X hours.
STATISTICS_HOURS = 24
###################################
# iRedAdmin related settings.
#
# Keep iRedAdmin admin log for days.
IREDADMIN_LOG_KEPT_DAYS = 365
#####################################################
# mlmmj and mlmmjadmin RESTful API related settings.
#
# The base url of newsletter subscription/unsubscription/error.
# The full url will be: https://domain.com/<NEWSLETTER_BASE_URL>
# WARNING: it must start with '/'
NEWSLETTER_BASE_URL = "/newsletter"
# How long (in hours) the subscription/unsubscription request will expire.
NEWSLETTER_SUBSCRIPTION_REQUEST_EXPIRE_HOURS = 24
NEWSLETTER_UNSUBSCRIPTION_REQUEST_EXPIRE_HOURS = 24
# How long (in hours) we should keep the subscription requests for simple statistics.
NEWSLETTER_SUBSCRIPTION_REQUEST_KEEP_HOURS = 24
# Base url of mlmmjadmin API. For example: 'http://127.0.0.1:7790/api'
MLMMJADMIN_API_BASE_URL = "http://127.0.0.1:7790/api"
# HTTP header used to store the API AUTH TOKEN.
# Defaults to 'X-MLMMJADMIN-API-AUTH-TOKEN'.
MLMMJADMIN_API_AUTH_HEADER = "X-MLMMJADMIN-API-AUTH-TOKEN"
# Verify SSL cert of mlmmjadmin API
MLMMJADMIN_API_VERIFY_SSL = False
# The transport name defined in Postfix master.cf used to call 'mlmmj-receive'
# program. For example:
#
# mlmmj unix - n n - - pipe
# flags=ORhu ...
MLMMJ_MTA_TRANSPORT_NAME = "mlmmj"
############################################################################
# Fail2ban integration.
#
# - Currently only querying banned IP from fail2ban SQL database is supported.
# - We use lower cases for parameter names to keep consistency with the ones
# in `settings.py`.
fail2ban_enabled = False
fail2ban_db_host = '127.0.0.1'
fail2ban_db_port = '3306'
fail2ban_db_name = 'fail2ban'
fail2ban_db_user = 'fail2ban'
fail2ban_db_password = ''
###################################
# Minor settings. You do not need to change them.
#
# Recipient delimiters. If you have multiple delimiters, please list them all.
RECIPIENT_DELIMITERS = ["+"]
# Show how many items in one page.
PAGE_SIZE_LIMIT = 50
# Smallest uid/gid number which can be assigned to new users/groups.
MIN_UID = 3000
MIN_GID = 3000
# The link to support page on iRedAdmin footer.
URL_SUPPORT = "https://www.iredmail.org/support.html"
# Path to the logo image and favicon.ico.
# Please copy your logo image to 'static/' folder, then put the image file name
# in BRAND_LOGO. e.g.: 'logo.png' (will load file 'static/logo.png').
BRAND_LOGO = ""
BRAND_FAVICON = ""
# Product name, short description.
BRAND_NAME = "iRedAdmin-Cracked"
BRAND_DESC = "iRedMail Admin Panel"
# Path to `sendmail` command
CMD_SENDMAIL = "/usr/sbin/sendmail"
# SMTP server address, port, username, password used to send notification mail.
NOTIFICATION_SMTP_SERVER = "localhost"
NOTIFICATION_SMTP_PORT = 587
NOTIFICATION_SMTP_STARTTLS = True
NOTIFICATION_SMTP_USER = "no-reply@localhost.local"
NOTIFICATION_SMTP_PASSWORD = ""
NOTIFICATION_SMTP_DEBUG_LEVEL = 0
# The short description or full name of this smtp user. e.g. 'No Reply'
NOTIFICATION_SENDER_NAME = "No Reply"
#
# Used in notification emails sent to recipients of quarantined emails.
#
# URL of your iRedAdmin-Pro login page which will be shown in notification
# email, so that user can login to manage quarantined emails.
# Sample: 'https://your_server.com/iredadmin/'
#
# Note: mail domain must have self-service enabled, otherwise normal
# mail user cannot login to iRedAdmin-Pro for self-service.
NOTIFICATION_URL_SELF_SERVICE = ""
# Subject of notification email. Available placeholders:
# - %(total)d -- number of quarantined mails in total
NOTIFICATION_QUARANTINE_MAIL_SUBJECT = "[Attention] You have %(total)d emails quarantined and not delivered to mailbox"

0
libs/f2b/__init__.py Normal file
View File

17
libs/f2b/log.py Normal file
View File

@@ -0,0 +1,17 @@
import web
from controllers import decorators
from libs.logger import logger
@decorators.require_global_admin
def num_banned() -> int:
total = 0
try:
_qr = web.conn_f2b.select("banned", what="COUNT(id) AS total")
total = _qr[0]['total']
except Exception as e:
logger.error(e)
return total

723
libs/form_utils.py Normal file
View File

@@ -0,0 +1,723 @@
"""Functions used to extract required data from web form."""
import settings
from libs import iredutils, iredpwd
from libs.l10n import TIMEZONES
# Return single value of specified form name.
def get_single_value(form,
input_name,
default_value='',
is_domain=False,
is_email=False,
is_integer=False,
is_strict_ip=False,
is_ip_or_network=False,
to_lowercase=False,
to_uppercase=False,
to_string=False,
split_value=False,
split_separator=None,
strip_str_before_split=False,
strip_str=None):
v = form.get(input_name, '')
if not v:
v = default_value
if not isinstance(v, (int, float)):
try:
v = v.strip()
except:
pass
if is_domain:
if not iredutils.is_domain(v):
return ''
if is_email:
if not iredutils.is_email(v):
v = default_value
if is_integer:
try:
v = int(v)
except:
v = default_value
if is_strict_ip:
if not iredutils.is_strict_ip(v):
return ''
if is_ip_or_network:
if not iredutils.is_ip_or_network(v):
return ''
if to_string:
try:
if isinstance(v, (list, tuple)):
v = [str(i) for i in v]
else:
v = str(v)
except:
pass
if to_lowercase:
if isinstance(v, (list, tuple)):
v = [i.lower() for i in v]
else:
v = v.lower()
if to_uppercase:
if isinstance(v, (list, tuple)):
v = [i.upper() for i in v]
else:
v = v.upper()
if split_value:
# return a list
if isinstance(v, str):
if strip_str_before_split:
if not strip_str:
strip_str = ' '
v.strip(strip_str)
if split_separator:
v = v.split(split_separator)
else:
v = v.split()
# Remove empty values
v = [i for i in v if i]
return v
# Return single value of specified form name.
def get_multi_values(form,
input_name,
default_value=None,
input_is_textarea=False,
is_domain=False,
is_email=False,
is_ip_or_network=False,
to_lowercase=False,
to_uppercase=False,
to_string=False):
v = form.get(input_name)
if v:
if input_is_textarea:
v = v.splitlines()
v = [i.strip() for i in v]
else:
if default_value is None:
v = []
else:
v = default_value
# Remove duplicate items.
try:
v = list(set(v))
except:
v = []
if is_domain:
v = [str(i).lower() for i in v if iredutils.is_domain(i)]
if is_email:
v = [str(i).lower() for i in v if iredutils.is_email(i)]
if is_ip_or_network:
v = [str(i) for i in v if iredutils.is_ip_or_network(i)]
if to_lowercase:
if not (is_domain or is_email):
v = [i.lower() for i in v]
if to_uppercase:
if not (is_domain or is_email):
v = [i.upper() for i in v]
if to_string:
v = [str(i) for i in v]
v.sort()
return v
def get_multi_values_from_api(form,
input_name,
to_string=True,
to_lowercase=True,
is_domain=False,
is_email=False):
"""Param/value posted from API will be: key=value1,value2,value3,...
This function extract values and return them as a list.
"""
values = get_single_value(form=form,
input_name=input_name,
to_string=to_string,
to_lowercase=to_lowercase,
split_value=True,
split_separator=',',
strip_str_before_split=True)
if is_domain:
values = [i for i in values if iredutils.is_domain(i)]
if is_email:
values = [i for i in values if iredutils.is_email(i)]
return list(set(values))
def get_multi_values_from_textarea(form,
input_name,
is_domain=False,
is_email=False,
to_lowercase=False):
"""Param/value posted from API will be: key=value1,value2,value3,...
This function extract values and return them as a list.
"""
v = get_single_value(form=form,
input_name=input_name,
to_string=True,
to_lowercase=to_lowercase,
split_value=True,
split_separator=None,
strip_str_before_split=True)
if is_domain:
v = [i for i in v if iredutils.is_domain(i)]
if is_email:
v = [i for i in v if iredutils.is_email(i)]
return v
def get_form_dict(form,
input_name,
key_name=None,
multi_values=False,
default_value='',
input_is_textarea=False,
is_domain=False,
is_email=False,
is_integer=False,
to_lowercase=False,
to_uppercase=False,
to_string=False):
d = {}
if input_name in form:
if multi_values:
# Value is a list
v = get_multi_values(form,
input_name,
default_value=default_value,
input_is_textarea=input_is_textarea,
is_domain=is_domain,
is_email=is_email,
to_lowercase=to_lowercase,
to_uppercase=to_uppercase)
else:
v = get_single_value(form,
input_name=input_name,
default_value=default_value,
is_domain=is_domain,
is_email=is_email,
is_integer=is_integer,
to_lowercase=to_lowercase,
to_uppercase=to_uppercase,
to_string=to_string)
# Convert values of some parameters
if settings.backend == 'ldap':
if input_name == 'accountStatus':
# When 'accountStatus' is used by a checkbox, its value will
# be 'on' which means the checkbox is checked.
if v in ['enable', 'active', 'yes', 'on', 1]:
v = 'active'
else:
v = 'disabled'
elif input_name == 'isGlobalAdmin':
if v != 'yes':
v = None
elif input_name in ['quota', 'defaultQuota', 'maxUserQuota',
'minPasswordLength', 'maxPasswordLength',
'numberOfUsers', 'numberOfAliases',
'numberOfLists']:
# Require integer number
try:
v = int(v)
except:
# Don't return any value.
return {}
else:
if input_name in ['accountStatus', 'backupmx']:
if v in ['enable', 'active', 'yes', 1]:
v = 1
else:
v = 0
if key_name:
d[key_name] = v
else:
if settings.backend == 'ldap':
# Map some input names to LDAP attribute names
# Warning: do not map the key names used in accountSetting.
if input_name == 'name':
key_name = 'cn'
elif input_name == 'accountStatus':
key_name = input_name
elif input_name == 'language':
key_name = 'preferredLanguage'
elif input_name == 'transport':
key_name = 'mtaTransport'
else:
key_name = input_name
else:
key_name = input_name
d[key_name] = v
return d
def get_name(form, input_name='cn'):
return get_single_value(form,
input_name=input_name,
default_value='')
def get_domain_name(form, input_name='domainName'):
return get_single_value(form,
input_name=input_name,
default_value='',
is_domain=True,
to_lowercase=True,
to_string=True)
def get_domain_names(form, input_name='domainName'):
return get_multi_values(form,
input_name=input_name,
default_value=[],
is_domain=True,
to_lowercase=True)
# Get default language for new mail user from web form.
def get_language(form, input_name='preferredLanguage'):
lang = get_single_value(form, input_name=input_name, to_string=True)
if lang not in iredutils.get_language_maps():
lang = ''
return lang
def get_domain_quota_and_unit(form,
input_quota='domainQuota',
input_quota_unit='domainQuotaUnit',
convert_to_mb=True):
"""Get domain quota and quota unit from web form, return a dict contains
quota (in MB) and ORIGINAL quota unit: {'quota': <integer>, 'unit': <string>}.
"""
# multiply is used for SQL backends.
quota = str(form.get(input_quota))
if quota.isdigit():
quota = abs(int(quota))
else:
quota = 0
quota_unit = str(form.get(input_quota_unit, 'MB'))
if quota > 0:
# Convert to MB
if convert_to_mb:
if quota_unit == 'GB':
quota = quota * 1024
elif quota_unit == 'TB':
quota = quota * 1024 * 1024
return {'quota': quota, 'unit': quota_unit}
# Get mailbox quota (in MB).
def get_quota(form, input_name='defaultQuota', default=0):
quota = str(form.get(input_name))
if quota.isdigit():
quota = abs(int(quota))
if input_name == 'maxUserQuota':
quota_unit = str(form.get('maxUserQuotaUnit', 'MB'))
if quota_unit == 'TB':
quota = quota * 1024 * 1024
elif quota_unit == 'GB':
quota = quota * 1024
else:
# MB
pass
else:
quota = default
return quota
def get_account_status(form,
input_name='accountStatus',
default_value='active',
to_integer=False):
status = get_single_value(form, input_name=input_name, to_string=True)
if not (status in ['active', 'disabled']):
status = default_value
# SQL backends store the account status as `active=[1|0]`
# LDAP backends store the account status as `accountStatus=[active|disabled]`
if to_integer:
if status == 'active':
return 1
else:
return 0
else:
return status
def get_password(form,
input_name='newpw',
confirm_pw_input_name='confirmpw',
min_passwd_length=None,
max_passwd_length=None):
pw = get_single_value(form,
input_name=input_name,
to_string=True)
confirm_pw = get_single_value(form,
input_name=confirm_pw_input_name,
to_string=True)
qr = iredpwd.verify_new_password(newpw=pw,
confirmpw=confirm_pw,
min_passwd_length=min_passwd_length,
max_passwd_length=max_passwd_length)
if not qr[0]:
return qr
if 'store_password_in_plain_text' in form and settings.STORE_PASSWORD_IN_PLAIN:
pw_hash = iredpwd.generate_password_hash(pw, pwscheme='PLAIN')
else:
pw_hash = iredpwd.generate_password_hash(pw)
return True, {'pw_plain': pw, 'pw_hash': pw_hash}
def get_timezone(form, input_name='timezone'):
tz = get_single_value(form,
input_name=input_name,
to_string=True)
if tz in TIMEZONES:
return tz
return None
def get_list_access_policy(form,
input_name='accessPolicy',
default_value='public'):
policy = get_single_value(form=form,
input_name=input_name,
default_value=default_value,
to_string=True)
if policy not in iredutils.MAILLIST_ACCESS_POLICIES:
policy = 'public'
return policy
# iRedAPD: Get throttle setting for
def get_throttle_setting(form, account, inout_type='inbound'):
# inout_type -- inbound, outbound.
var_enable_throttle = 'enable_%s_throttling' % inout_type
# not enabled.
if var_enable_throttle not in form:
return {}
# name of form <input> tag:
# [inout_type]_[name]
# custom_[inout_type]_[name]
# Pre-defined values
setting = {'account': account,
'priority': iredutils.get_account_priority(account),
'period': 0,
'max_msgs': 0,
'max_quota': 0,
'msg_size': 0,
'kind': inout_type}
input_keys = ['period', 'max_msgs', 'max_quota', 'msg_size']
if inout_type == "outbound":
setting["max_rcpts"] = 0
input_keys.append("max_rcpts")
for k in input_keys:
var = inout_type + '_' + k
# Get pre-defined value first
v = form.get(var, '')
if v == 'on':
# Get custom value if it's not pre-defined
v = form.get('custom_' + var)
try:
v = int(v)
setting[k] = v
except:
continue
# Return empty dict if all values are 0.
return setting
# NOTE: used by LDAP backends.
def update_domain_creation_settings(form,
account_settings,
check_creation_permission=True):
"""Update `account_settings` with data from form.
:param form: web form data
:param account_settings: dict of per-admin account settings
:param check_creation_permission: check whether html tag
"<input name='allowed_to_create_domain' ... />" exists, used in user
profile page.
"""
_allowed = True
if check_creation_permission:
if 'allowed_to_create_domain' not in form:
_allowed = False
if _allowed:
for i in ['create_max_domains',
'create_max_quota',
'create_max_users',
'create_max_aliases',
'create_max_lists']:
if i in form:
try:
v = int(form.get(i, '0'))
except:
v = 0
if v > 0:
account_settings[i] = v
else:
if i in account_settings:
account_settings.pop(i)
for i in ['disable_domain_ownership_verification']:
if i in form:
account_settings[i] = 'yes'
else:
if i in account_settings:
account_settings.pop(i)
if 'create_max_quota' in account_settings:
if 'create_quota_unit' in form:
v = form.get('create_quota_unit', 'TB')
if v in ['TB', 'GB']:
account_settings['create_quota_unit'] = v
else:
if 'create_quota_unit' in account_settings:
account_settings.pop('create_quota_unit')
for i in ['create_max_domains',
'create_max_quota',
'create_max_users',
'create_max_aliases',
'create_max_lists']:
if i in account_settings:
account_settings['create_new_domains'] = 'yes'
break
else:
# Remove account_settings['create_new_domains']
try:
account_settings.pop('create_new_domains')
except:
pass
else:
for i in ['create_new_domains',
'create_max_domains',
'create_max_quota',
'create_max_users',
'create_max_aliases',
'create_max_lists',
'disable_domain_ownership_verification']:
if i in account_settings:
account_settings.pop(i)
return account_settings
# NOTE: used by LDAP backends.
def get_domain_creation_settings(form):
"""Get per-admin domain creation limits from web form."""
d = {}
kv = get_form_dict(form=form,
input_name='create_max_domains',
default_value=0,
is_integer=True)
if kv:
d.update(kv)
d['create_new_domains'] = 'yes'
kv = get_form_dict(form=form,
input_name='create_max_users',
default_value=0,
is_integer=True)
d.update(kv)
kv = get_form_dict(form=form,
input_name='create_max_aliases',
default_value=0,
is_integer=True)
d.update(kv)
kv = get_form_dict(form=form,
input_name='create_max_lists',
default_value=0,
is_integer=True)
d.update(kv)
# format: 10TB, 10GB, 10MB.
kv = get_form_dict(form=form,
input_name='create_max_quota',
default_value=0,
is_integer=True)
if kv:
_kv = get_form_dict(form=form,
input_name='create_quota_unit',
default_value='MB',
to_uppercase=True,
to_string=True)
d.update(_kv)
d.update(kv)
# Discard item which has value == '0'
if d:
for (k, v) in list(d.items()):
if v == 0:
d.pop(k)
if k == 'create_max_quota':
if 'create_quota_unit' in d:
d.pop('create_quota_unit')
return d
#
# mlmmj
#
def get_mlmmj_params_from_web_form(form):
"""Convert parameter names/values in web form to mlmmj parameters."""
mlmmj_params = form.copy()
# Remove parameters used by web form but not mlmmjadmin API
for k in ['csrf_token', 'modified', 'active', 'accountStatus']:
if k in mlmmj_params:
mlmmj_params.pop(k)
#
# Get access policy
#
if 'accessPolicy' in form:
access_policy = form.get('accessPolicy', '').lower()
mlmmj_params.pop('accessPolicy')
else:
access_policy = 'public'
if access_policy not in iredutils.ML_ACCESS_POLICIES:
access_policy = 'public'
mlmmj_params['access_policy'] = access_policy
mlmmj_params['only_subscriber_can_post'] = 'no'
mlmmj_params['only_moderator_can_post'] = 'no'
if access_policy == 'membersonly':
mlmmj_params['only_subscriber_can_post'] = 'yes'
elif access_policy == 'moderatorsonly':
mlmmj_params['only_moderator_can_post'] = 'yes'
#
# Get max message size (in bytes)
#
mlmmj_params['max_message_size'] = 0
# `max_mail_size` and `max_mail_size_unit` are used by web form.
_size = form.get('max_mail_size', 0)
_unit = form.get('max_mail_size_unit', 'KB')
try:
_size = int(_size)
except:
pass
if _size:
if _unit == 'KB':
mlmmj_params['max_message_size'] = _size * 1024
elif _unit == 'MB':
mlmmj_params['max_message_size'] = _size * 1024 * 1024
if 'max_mail_size' in mlmmj_params:
mlmmj_params.pop('max_mail_size')
if 'max_mail_size_unit' in mlmmj_params:
mlmmj_params.pop('max_mail_size_unit')
# Other radio/checkbox options.
for (k, v) in list(mlmmj_params.items()):
# mlmmjadmin API expects values in 'yes', 'no'.
if v == 'on':
mlmmj_params[k] = 'yes'
# Rename 'hidden_<key>' to '<key>'
if k.startswith('hidden_'):
nk = k.replace('hidden_', '') # don't use `string.lstrip()`
mlmmj_params.pop(k)
if nk not in mlmmj_params:
mlmmj_params[nk] = 'no'
return mlmmj_params
def get_mlmmj_params_from_api(form):
"""Convert parameter names/values in API form to mlmmjadmin parameters.
:param form: dict of web form.
It also supports all parameters supported by mlmmjadmin.
"""
# `kvs` stores mlmmj parameters
kvs = form.copy()
#
# Get max message size (in bytes)
#
if 'max_message_size' in form:
kvs['max_message_size'] = 0
try:
_size = abs(int(form.get('max_message_size', 0)))
kvs['max_message_size'] = _size
except:
kvs.pop('max_message_size')
return kvs

16
libs/hooks.py Normal file
View File

@@ -0,0 +1,16 @@
import web
def hook_set_language():
# parameter `lang` in URI. e.g. https://xxx/?lang=en_US
_lang = web.input(lang=None, _method="GET").get("lang")
# parameter `lang` in session.
if not _lang:
_lang = web.config.get("_session", {}).get("lang")
web.ctx.lang = _lang or "en_US"
def hook_session():
pass

0
libs/iredapd/__init__.py Normal file
View File

535
libs/iredapd/greylist.py Normal file
View File

@@ -0,0 +1,535 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
from libs import iredutils
from libs.logger import logger
def get_all_greylist_settings():
"""Return all existing greylisting settings."""
gl_settings = {}
try:
qr = web.conn_iredapd.select(
'greylisting',
what='id, account, sender, active',
)
if qr:
gl_settings = list(qr)
except Exception as e:
logger.error(e)
return gl_settings
def get_greylist_setting(account=None):
"""Return greylisting setting of specified account."""
gl_setting = {}
if not account:
account = '@.'
if not iredutils.is_valid_amavisd_address(account):
return gl_setting
try:
qr = web.conn_iredapd.select(
'greylisting',
vars={'account': account},
what='id, account, sender, active',
where="""account = $account AND sender='@.'""",
limit=1,
)
if qr:
gl_setting = qr[0]
except Exception as e:
logger.error(e)
return gl_setting
def get_greylist_whitelists(account, address_only=False):
"""Return greylisting whitelists of specified account."""
if not iredutils.is_valid_amavisd_address(account):
return []
whitelists = []
try:
qr = web.conn_iredapd.select(
'greylisting_whitelists',
vars={'account': account},
what='id, sender, comment',
where='account = $account',
order='sender',
)
if qr:
whitelists = list(qr)
# Don't explore SQL structure, just export the sender addresses
if address_only and whitelists:
wl = []
for i in whitelists:
wl.append(i.sender.lower())
whitelists = wl
except Exception as e:
logger.error(e)
return whitelists
def get_greylist_whitelist_domains():
"""Return greylisting whitelist domains of specified account."""
domains = []
try:
qr = web.conn_iredapd.select(
'greylisting_whitelist_domains',
what='domain',
order='domain',
)
if qr:
for i in qr:
domains.append(str(i.domain).lower())
except Exception as e:
logger.error(e)
return domains
def delete_greylist_setting(account, senders=None):
"""Delete greylisting setting of specified account."""
if not iredutils.is_valid_amavisd_address(account):
return True
try:
if senders:
web.conn_iredapd.delete(
'greylisting',
vars={'account': account, 'senders': senders},
where="""account = $account AND sender IN $sender""",
)
else:
web.conn_iredapd.delete(
'greylisting',
vars={'account': account},
where="""account = $account""",
)
return True,
except Exception as e:
return False, repr(e)
def enable_disable_greylist_setting(account, enable=False):
"""Update (or create) greylisting setting of specified account."""
account_type = iredutils.is_valid_amavisd_address(account)
if not account_type:
return False, 'INVALID_ACCOUNT'
active = 0
if enable:
active = 1
gl_setting = {'account': account,
'priority': iredutils.IREDAPD_ACCOUNT_PRIORITIES.get(account_type, 0),
'sender': '@.',
'sender_priority': 0,
'active': active}
try:
# Delete existing record first.
web.conn_iredapd.delete(
'greylisting',
vars={'account': account, 'sender': gl_setting['sender']},
where='account = $account AND sender = $sender',
)
# Create new record
web.conn_iredapd.insert('greylisting', **gl_setting)
except Exception as e:
return False, repr(e)
return True,
def reset_greylist_whitelist_domains(domains=None):
"""Update greylisting whitelist domains for specified account.
@domains -- must be a list/tuple/set
@conn -- sql connection cursor
"""
# Delete existing records first
try:
web.conn_iredapd.delete('greylisting_whitelist_domains', where='1=1')
except Exception as e:
return False, repr(e)
# Insert new records
if domains:
values = []
for d in domains:
values += [{'domain': d}]
try:
web.conn_iredapd.multiple_insert('greylisting_whitelist_domains', values=values)
except Exception as e:
return False, repr(e)
return True, 'GL_WLD_UPDATED'
def update_greylist_whitelist_domains(new=None, removed=None):
"""Add new or remove existing whitelist SPF domains for greylisting service.
@new - must be a list/tuple/set of sender domains
@removed - must be a list/tuple/set of sender domains
@conn - sql connection cursor
"""
_new = []
if new:
_new = [str(i).lower()
for i in new
if iredutils.is_domain(i)]
_new = list(set(_new))
_removed = []
if removed:
_removed = [str(i).lower()
for i in removed
if iredutils.is_domain(i)]
_removed = list(set(_removed))
# Remove duplicates
_removed = [i for i in _removed if i not in _new]
if not (_new or _removed):
return True,
# Insert new whitelists
if _new:
for i in _new:
try:
web.conn_iredapd.insert('greylisting_whitelist_domains', domain=i)
except Exception as e:
logger.error(e)
# Remove existing ones
if _removed:
try:
web.conn_iredapd.delete(
'greylisting_whitelist_domains',
vars={'removed': _removed},
where='domain IN $removed',
)
except Exception as e:
logger.error(e)
return True,
def reset_greylist_whitelists(account, whitelists=None):
"""Reset greylisting whitelists for specified account.
If `whitelists` is empty, all existing whitelists will be removed.
@whitelists - must be a list/tuple/set of whitelist senders, or a list of
dict which maps to sql column/value pairs. e.g.
[{'account': '@.',
'sender': '192.168.1.1',
'comment': ''},
...]
"""
if not iredutils.is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
# Delete existing whitelists first
try:
web.conn_iredapd.delete(
'greylisting_whitelists',
vars={'account': account},
where='account = $account',
)
except Exception as e:
return False, repr(e)
# Insert new whitelists
if whitelists:
for w in whitelists:
if isinstance(w, dict):
try:
web.conn_iredapd.insert('greylisting_whitelists', **w)
except:
pass
elif isinstance(w, str):
try:
web.conn_iredapd.insert(
'greylisting_whitelists',
account=account,
sender=w,
)
except:
pass
return True,
def update_greylist_whitelists(account, new=None, removed=None):
"""Add new or remove existing greylisting whitelists for specified account.
:param account: must be an valid iRedAPD account
:param new: must be a list/tuple/set of whitelist senders
:param removed: must be a list/tuple/set of whitelist senders
"""
if not iredutils.is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
_new = []
if new:
_new = [str(i).lower()
for i in new
if iredutils.is_valid_wblist_address(i)]
_new = list(set(_new))
_removed = []
if removed:
_removed = [str(i).lower()
for i in removed
if iredutils.is_valid_wblist_address(i)]
_removed = list(set(_removed))
# Remove duplicates
_removed = [i for i in _removed if i not in _new]
if not (_new or _removed):
return True,
# Insert new whitelists
if _new:
for w in _new:
try:
web.conn_iredapd.insert(
'greylisting_whitelists',
account=account,
sender=w,
)
except:
pass
# Remove existing ones
if _removed:
try:
web.conn_iredapd.delete(
'greylisting_whitelists',
vars={'removed': removed},
where='sender IN $removed',
)
except:
pass
return True,
def update_greylist_settings_from_form(account, form):
# Enable/disable greylisting
# @inherit - inherit from global setting
# @enable - explicitly enable
# @disable - explicitly disable
_gl_value = form.get('greylisting', 'inherit')
if _gl_value == 'inherit':
# Delete greylisting setting
qr = delete_greylist_setting(account=account)
elif _gl_value == 'enable':
qr = enable_disable_greylist_setting(account=account, enable=True)
elif _gl_value == 'disable':
qr = enable_disable_greylist_setting(account=account, enable=False)
else:
return True, 'GL_UPDATED'
if qr[0] is not True:
return qr
# Update greylisting whitelist domains.
if account == '@.':
wl_domains = set()
lines = form.get('whitelist_domains', '').splitlines()
for line in lines:
if iredutils.is_domain(line):
wl_domains.add(str(line).lower())
qr = reset_greylist_whitelist_domains(domains=wl_domains)
if not qr[0]:
return qr
# Update greylisting whitelists.
whitelists = []
# Store senders to avoid duplicate
_senders = set()
lines = form.get('whitelists', '').splitlines()
for line in lines:
# Split sender and comment with '#'
wl = line.split('#', 1)
sender = ''
comment = ''
if len(wl) == 1:
sender = str(wl[0]).strip()
comment = ''
elif len(wl) == 2:
sender = str(wl[0]).strip()
comment = wl[1].strip()
# Validate sender.
if not iredutils.is_valid_wblist_address(sender):
continue
if sender not in _senders:
whitelists += [{'account': account, 'sender': sender, 'comment': comment}]
_senders.add(sender)
qr = reset_greylist_whitelists(account=account, whitelists=whitelists)
if qr[0]:
return True, 'GL_UPDATED'
else:
return qr
def delete_settings_for_removed_users(mails):
mails = [str(v).lower() for v in mails if iredutils.is_email(v)]
if not mails:
return True,
try:
# Delete settings for user
web.conn_iredapd.delete(
'greylisting',
vars={'mails': mails},
where="""account IN $mails""",
)
# Delete whitelists
web.conn_iredapd.delete(
'greylisting_whitelists',
vars={'mails': mails},
where='account IN $mails',
)
# Delete greylisting tracking
web.conn_iredapd.delete(
'greylisting_tracking',
vars={'mails': mails},
where="""recipient IN $mails""",
)
return True,
except Exception as e:
return False, repr(e)
def delete_settings_for_removed_domain(domain):
if not iredutils.is_domain(domain):
return True,
try:
# Delete settings for domain ('@domain.com')
web.conn_iredapd.delete(
'greylisting',
vars={'domain': '@' + domain},
where='account=$domain',
)
# Delete settings for all users under this domain
web.conn_iredapd.delete(
'greylisting',
vars={'domain': '%@' + domain},
where="""account LIKE $domain""",
)
# Delete whitelists
web.conn_iredapd.delete(
'greylisting_whitelists',
vars={'domain': '@' + domain},
where='account=$domain',
)
web.conn_iredapd.delete(
'greylisting_whitelists',
vars={'domain': '%@' + domain},
where='account LIKE $domain',
)
# Delete greylisting tracking
web.conn_iredapd.delete(
'greylisting_tracking',
vars={'domain': domain},
where='rcpt_domain=$domain',
)
return True,
except Exception as e:
return False, repr(e)
def get_tracking_data(account):
"""Get tracking data of given local account."""
_account_type = iredutils.is_valid_amavisd_address(account)
if not _account_type:
return True, []
try:
if _account_type == 'catchall':
# account = '@.'
qr = web.conn_iredapd.select(
'greylisting_tracking',
what='COUNT(blocked_count) AS total, sender_domain',
where='passed=0',
group='sender_domain',
order='total DESC',
)
elif _account_type == 'domain':
domain = account.lstrip('@')
qr = web.conn_iredapd.select(
'greylisting_tracking',
vars={'domain': domain},
where='sender_domain=$domain AND passed=0',
order='init_time DESC',
)
else:
return False, 'INVALID_ACCOUNT'
return True, list(qr)
except Exception as e:
return False, repr(e)
def get_domain_tracking_data(domain):
"""Get tracking data of given domain."""
domain = str(domain).lower()
return get_tracking_data(account='@' + domain)
def filter_whitelisted_ips(ips):
"""Return list of (globally) whitelisted IPs."""
ips = [i for i in ips if iredutils.is_strict_ip(i)]
if not ips:
return True, []
try:
qr = web.conn_iredapd.select(
'greylisting_whitelists',
vars={'account': '@.', 'ips': ips},
what='sender',
where='account=$account AND sender IN $ips',
order='sender',
)
whitelisted_ips = [i.sender for i in qr]
return True, whitelisted_ips
except Exception as e:
logger.error(e)
return False, repr(e)

286
libs/iredapd/log.py Normal file
View File

@@ -0,0 +1,286 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import time
import web
import settings
from libs import iredutils
from libs.logger import logger
if settings.backend == 'ldap':
from libs.ldaplib.admin import get_managed_domains
else:
from libs.sqllib.admin import get_managed_domains
session = web.config.get('_session')
def __get_managed_domains():
domains = []
kw = {'admin': session.get('username'),
'domain_name_only': True,
'conn': None}
if settings.backend != 'ldap':
kw['listed_only'] = True
qr = get_managed_domains(**kw)
if qr[0]:
domains = qr[1]
return domains
def get_num_rejected(hours=None):
"""Return amount of rejected mails in last given `hours`."""
num = 0
if not hours:
hours = 24
sql_vars = {
"action": "REJECT",
"time_num": (int(time.time()) - (hours * 3600)),
}
sql_wheres = ["action = $action AND time_num >= $time_num"]
if not session.get('is_global_admin'):
domains = __get_managed_domains()
if domains:
sql_vars['domains'] = domains
sql_wheres += ['(sender_domain IN $domains OR sasl_username IN $domains OR recipient_domain IN $domains)']
else:
return num
sql_where = ' AND '.join(sql_wheres)
try:
qr = web.conn_iredapd.select(
'smtp_sessions',
vars=sql_vars,
what="COUNT(id) AS total",
where=sql_where,
)
if qr:
num = qr[0]['total']
except Exception as e:
logger.error(e)
return num
def get_num_smtp_outbound_sessions(hours=None):
"""Return amount of smtp authentications in last given `hours`."""
num = 0
if not hours:
hours = 24
sql_vars = {
"time_num": (int(time.time()) - (hours * 3600)),
}
sql_wheres = ["sasl_username <> '' AND time_num >= $time_num"]
if not session.get('is_global_admin'):
domains = __get_managed_domains()
if domains:
sql_vars['domains'] = domains
sql_wheres += ['sasl_domain IN $domains']
else:
return num
sql_where = ' AND '.join(sql_wheres)
try:
qr = web.conn_iredapd.select(
'smtp_sessions',
vars=sql_vars,
what="COUNT(id) AS total",
where=sql_where,
)
if qr:
num = qr[0]['total']
except Exception as e:
logger.error(e)
return num
def get_log_smtp_sessions(domains=None,
sasl_usernames=None,
senders=None,
recipients=None,
client_addresses=None,
encryption_protocols=None,
outbound_only=False,
rejected_only=False,
offset=None,
limit=None):
"""Return a dict with amount of smtp rejections and list of (SQL) rows."""
result = {'total': 0, 'rows': []}
if not offset or not isinstance(offset, int):
offset = 0
if not limit or not isinstance(limit, int):
limit = settings.PAGE_SIZE_LIMIT
query_domains = []
sql_vars = {}
sql_wheres = []
sql_where = None
if domains:
query_domains = [str(i).lower() for i in domains if iredutils.is_domain(i)]
if session.get('is_global_admin'):
if query_domains:
sql_vars['domains'] = query_domains
if outbound_only:
sql_wheres += ['sasl_domain IN $domains']
else:
sql_wheres += ['(sender_domain IN $domains OR sasl_domain IN $domains OR recipient_domain IN $domains)']
else:
if outbound_only:
sql_wheres += ["sasl_username <> ''"]
else:
managed_domains = __get_managed_domains()
if not managed_domains:
return result
if domains:
query_domains = [str(i).lower() for i in domains if i in managed_domains]
if not query_domains:
return result
else:
query_domains = managed_domains
sql_vars['domains'] = query_domains
if outbound_only:
sql_wheres += ['sasl_domain in $domains']
else:
sql_wheres += ['(sender_domain IN $domains OR sasl_domain IN $domains OR recipient_domain IN $domains)']
if sasl_usernames:
sql_vars['sasl_usernames'] = [str(i).lower() for i in sasl_usernames if iredutils.is_email(i)]
sql_wheres += ['sasl_username IN $sasl_usernames']
if senders:
sql_vars['senders'] = [str(i).lower() for i in senders if iredutils.is_email(i)]
sql_wheres += ['sender IN $senders']
if recipients:
sql_vars['recipients'] = [str(i).lower() for i in recipients if iredutils.is_email(i)]
sql_wheres += ['recipient IN $recipients']
if client_addresses:
sql_vars['client_addresses'] = [i for i in client_addresses if iredutils.is_strict_ip(i)]
sql_wheres += ['client_address IN $client_addresses']
if encryption_protocols:
sql_vars['encryption_protocols'] = encryption_protocols
sql_wheres += ['encryption_protocol IN $encryption_protocols']
if rejected_only:
sql_wheres += ["action='REJECT'"]
if sql_wheres:
sql_where = ' AND '.join(sql_wheres)
try:
qr = web.conn_iredapd.select(
'smtp_sessions',
vars=sql_vars,
what='COUNT(id) AS total',
where=sql_where,
)
if qr:
result['total'] = qr[0].total
except Exception as e:
logger.error(e)
columns = [
'id', 'time', 'time_num',
'action', 'reason', 'instance',
'sasl_username', 'sender', 'recipient',
'client_address', 'encryption_protocol',
]
try:
qr = web.conn_iredapd.select(
'smtp_sessions',
vars=sql_vars,
what=','.join(columns),
where=sql_where,
order='time_num DESC',
offset=offset,
limit=limit,
)
if qr:
result['rows'] = list(qr)
except Exception as e:
logger.error(e)
return result
def get_smtp_insecure_outbound(hours=None):
"""
Return info of insecure smtp outbound sessions in last given `hours`.
(True, {'total': '<int>', 'usernames': [<mail>, <mail>, ...]})
(False, '<error>')
"""
result = {'total': 0, 'usernames': []}
if not isinstance(hours, int):
hours = 24
sql_vars = {
"time_num": (int(time.time()) - (hours * 3600)),
}
sql_wheres = ["sasl_username <> '' AND encryption_protocol = '' AND time_num >= $time_num"]
if not session.get('is_global_admin'):
domains = __get_managed_domains()
if domains:
sql_vars['domains'] = domains
sql_wheres += ['sasl_domain IN $domains']
else:
return True, result
sql_where = ' AND '.join(sql_wheres)
try:
qr = web.conn_iredapd.select(
'smtp_sessions',
vars=sql_vars,
what='sasl_username',
where=sql_where,
group='sasl_username',
)
for row in qr:
result['total'] += 1
_email = str(row['sasl_username']).lower().strip()
result['usernames'].append(_email)
result['usernames'].sort()
return True, result
except Exception as e:
logger.error(e)
return False, repr(e)

180
libs/iredapd/throttle.py Normal file
View File

@@ -0,0 +1,180 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
from libs import iredutils
def get_throttle_setting(account, inout_type='outbound'):
"""Get throttle setting.
@account -- a valid throttling account
@inout_type -- inbound, outbound
"""
setting = {}
if not iredutils.is_valid_amavisd_address(account):
return setting
qr = web.conn_iredapd.select(
'throttle',
vars={'account': account, 'inout_type': inout_type},
where='kind=$inout_type AND account=$account',
limit=1,
)
if qr:
setting = qr[0]
return setting
def delete_throttle_setting(account, inout_type):
if not iredutils.is_valid_amavisd_address(account):
return False, 'INVALID_ACCOUNT'
if not (inout_type in ['inbound', 'outbound']):
return False, 'INVALID_INOUT_TYPE'
if account and inout_type:
web.conn_iredapd.delete(
'throttle',
vars={'account': account, 'inout_type': inout_type},
where='account=$account AND kind=$inout_type',
)
return True,
return True,
def delete_throttle_tracking(account, inout_type):
tid = get_throttle_id(account, inout_type)
if tid:
try:
web.conn_iredapd.delete(
'throttle_tracking',
vars={'tid': tid},
where='tid=$tid',
)
except Exception as e:
return False, repr(e)
return True,
def delete_settings_for_removed_users(mails):
mails = [str(v).lower() for v in mails if iredutils.is_email(v)]
if not mails:
return True,
try:
web.conn_iredapd.delete(
'throttle',
vars={'mails': mails},
where="""account IN $mails""",
)
web.conn_iredapd.delete(
'throttle_tracking',
vars={'mails': mails},
where="""account IN $mails""",
)
return True,
except Exception as e:
return False, repr(e)
def delete_settings_for_removed_domain(domain):
if not iredutils.is_domain(domain):
return True,
try:
# Delete settings for domain ('@domain.com')
web.conn_iredapd.delete(
'throttle',
vars={'domain': '@' + domain},
where='account=$domain',
)
# Delete settings for all users under this domain
web.conn_iredapd.delete(
'throttle',
vars={'domain': '%@' + domain},
where="""account LIKE $domain""")
web.conn_iredapd.delete(
'throttle_tracking',
vars={'domain': '%@' + domain},
where="""account LIKE $domain""",
)
return True,
except Exception as e:
return False, repr(e)
def get_throttle_id(account, inout_type):
tid = None
# get `throttle.id`
qr = web.conn_iredapd.select(
'throttle',
vars={'account': account, 'inout_type': inout_type},
where='account=$account AND kind=$inout_type',
limit=1,
)
if qr:
tid = qr[0].id
return tid
def add_throttle(account,
setting,
inout_type='inbound'):
if not setting:
# Delete tracking and setting
delete_throttle_tracking(account=account, inout_type=inout_type)
delete_throttle_setting(account=account, inout_type=inout_type)
return True,
# Delete record if
# - no period. (period == 0) means disabled
# - account mismatch
# - account is '@.' (global setting) and no valid setting (all are 0)
# - account is not '@.' (not global setting) and no valid setting (all are -1)
if (not setting.get('period', 0)) \
or (account != setting.get('account')) \
or (account == '@.'
and (not setting.get('max_msgs'))
and (not setting.get('msg_size'))
and (not setting.get('max_quota'))
and (not setting.get("max_rcpts"))) \
or (account != '@.'
and setting.get("max_msgs") == -1
and setting.get("msg_size") == -1
and setting.get("max_quota") == -1
and setting.get("max_rcpts") in (None, -1)):
delete_throttle_tracking(account=account, inout_type=inout_type)
delete_throttle_setting(account=account, inout_type=inout_type)
else:
try:
# Get `throttle.id` if there's a setting.
tid = get_throttle_id(account=account, inout_type=inout_type)
if tid:
# Update existing setting
web.conn_iredapd.update(
'throttle',
vars={'tid': tid},
where='id=$tid',
**setting)
else:
# Add new throttle setting.
web.conn_iredapd.insert('throttle', **setting)
except Exception as e:
return False, repr(e)
return True,

28
libs/iredapd/utils.py Normal file
View File

@@ -0,0 +1,28 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
from libs import iredutils
from libs.iredapd import throttle as iredapd_throttle
from libs.iredapd import greylist as iredapd_greylist
def delete_settings_for_removed_users(mails):
try:
iredapd_greylist.delete_settings_for_removed_users(mails=mails)
iredapd_throttle.delete_settings_for_removed_users(mails=mails)
return True,
except Exception as e:
return False, repr(e)
def delete_settings_for_removed_domains(domains):
domains = [str(d).lower() for d in domains if iredutils.is_domain(d)]
if not domains:
return True,
for d in domains:
iredapd_throttle.delete_settings_for_removed_domain(domain=d)
iredapd_greylist.delete_settings_for_removed_domain(domain=d)
return True,

View File

@@ -0,0 +1,66 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
def get_wblist_rdns():
"""Get wblist of rDNS."""
whitelists = []
blacklists = []
try:
qr = web.conn_iredapd.select(
'wblist_rdns',
what='rdns,wb',
order='rdns',
)
for i in qr:
_rdns = str(i.rdns).lower()
if i.wb == 'W':
whitelists.append(_rdns)
elif i.wb == 'B':
blacklists.append(_rdns)
return True, {'whitelists': whitelists, 'blacklists': blacklists}
except Exception as e:
return False, repr(e)
def reset_wblist_rdns(whitelists=None, blacklists=None):
"""Reset wblist rdns.
@whitelists -- a list/tuple/set of whitelist rdns domain names. Notes:
- if it's None, no reset.
- if it's empty list/tuple/set, all existing records will be
removed.
@blacklists -- a list/tuple/set of blacklist rdns domain names.
@conn -- sql connection cursor
"""
if whitelists and blacklists:
# Remove duplicate records
blacklists = [i for i in blacklists if i not in whitelists]
# Delete first to avoid possible duplicate records while inserting new
# records later.
for (_lists, _wb) in [(whitelists, 'W'), (blacklists, 'B')]:
if _lists is not None:
try:
# Delete all existing records first
web.conn_iredapd.delete(
'wblist_rdns',
vars={'wb': _wb},
where='WB=$wb',
)
except Exception as e:
return False, repr(e)
# Insert new records
for (_lists, _wb) in [(whitelists, 'W'), (blacklists, 'B')]:
if _lists:
for i in _lists:
try:
web.conn_iredapd.insert('wblist_rdns', rdns=i, wb=_wb)
except:
pass
return True, 'UPDATED'

View File

@@ -0,0 +1,83 @@
import web
from libs import iredutils
# `4102444799` seconds since 1970-01-01 is '2099-12-31 23:59:59'.
# It's a trick to use this time as whitelist and not cleaned by
# script `tools/cleanup_db.py`.
# It's ok to use any long epoch seconds to avoid cleanup, but we use this
# hard-coded value for easier management.
expire_epoch_seconds = 4102444799
def get_whitelists():
total = 0
ips = []
try:
qr = web.conn_iredapd.select(
"senderscore_cache",
vars={'seconds': expire_epoch_seconds},
what='COUNT(client_address) AS total',
where="time=$seconds",
)
total = qr[0].total
if total:
qr = web.conn_iredapd.select(
"senderscore_cache",
vars={'seconds': expire_epoch_seconds},
what='client_address',
where="time=$seconds",
)
ips = [i.client_address for i in qr]
return True, {'total': total, 'ips': ips}
except Exception as e:
return False, repr(e)
def filter_whitelisted_ips(ips):
# Return a list of whitelisted IP addresses of given ones.
ips = [i for i in ips if iredutils.is_strict_ip(i)]
try:
qr = web.conn_iredapd.select(
"senderscore_cache",
vars={'ips': ips, 'seconds': expire_epoch_seconds},
what='client_address',
where="client_address IN $ips AND time=$seconds",
)
ips = [i.client_address for i in qr]
return True, ips
except Exception as e:
return False, repr(e)
def whitelist_ips(ips):
# Whitelist given IP addresses.
ips = [i for i in ips if iredutils.is_strict_ip(i)]
if not ips:
return True,
# Remove existing records first.
try:
web.conn_iredapd.delete("senderscore_cache",
vars={'ips': ips},
where="client_address IN $ips")
rows = []
for ip in ips:
rows += [{'client_address': ip,
'score': 100,
'time': expire_epoch_seconds}]
# Insert whitelists.
web.conn_iredapd.multiple_insert("senderscore_cache", rows)
return True,
except Exception as e:
return False, repr(e)

206
libs/iredbase.py Normal file
View File

@@ -0,0 +1,206 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import os
import web
from jinja2 import Environment, FileSystemLoader
# Directory to be used as the Python egg cache directory.
# Note that the directory specified must exist and be writable by the
# user that the daemon process run as.
os.environ["PYTHON_EGG_CACHE"] = "/tmp/.iredadmin-eggs"
os.environ["LC_ALL"] = "C"
# Absolute path to this file.
rootdir = os.path.abspath(os.path.dirname(__file__))
import settings
from . import iredutils
from . import iredpwd
from . import jinja_filters
from . import ireddate
from . import hooks
# Set debug mode.
web.config.debug = settings.DEBUG
# Set session parameters.
web.config.session_parameters["cookie_name"] = "iRedAdmin-Pro-%s" % settings.backend.upper()
web.config.session_parameters["cookie_domain"] = None
web.config.session_parameters["ignore_expiry"] = True
web.config.session_parameters["ignore_change_ip"] = settings.SESSION_IGNORE_CHANGE_IP
web.config.session_parameters["timeout"] = settings.SESSION_TIMEOUT
web.config.session_parameters["httponly"] = True
web.config.session_parameters["samesite"] = "Strict"
# web.config.session_parameters['secure'] = True
# Initialize session object.
__sql_dbn = "mysql"
if settings.backend == "pgsql":
__sql_dbn = "postgres"
conn_iredadmin = iredutils.get_db_conn(db_name="iredadmin", sql_dbn=__sql_dbn)
web.conn_iredadmin = conn_iredadmin
# URL handlers.
# Import backend related urls.
urls_backend = []
if settings.backend == "ldap":
from controllers.ldap.urls import urls as urls_backend
elif settings.backend in ["mysql", "pgsql"]:
from controllers.sql.urls import urls as urls_backend
urls = urls_backend
# Amavisd.
if (
settings.amavisd_enable_quarantine
or settings.amavisd_enable_logging
or settings.amavisd_enable_policy_lookup
):
from controllers.amavisd.urls import urls as urls_amavisd
urls += urls_amavisd
web.conn_amavisd = iredutils.get_db_conn(db_name="amavisd", sql_dbn=__sql_dbn)
else:
web.conn_amavisd = None
# iRedAPD
from controllers.iredapd.urls import urls as urls_iredapd
urls += urls_iredapd
if settings.iredapd_enabled:
web.conn_iredapd = iredutils.get_db_conn(db_name="iredapd", sql_dbn=__sql_dbn)
else:
web.conn_iredapd = None
# iRedAdmin.
from controllers.panel.urls import urls as urls_panel
urls += urls_panel
# mlmmj.
from controllers.mlmmj.urls import urls as urls_mlmmj
urls += urls_mlmmj
# Fail2ban.
if settings.fail2ban_enabled:
from controllers.f2b.urls import urls as urls_f2b
urls += urls_f2b
web.conn_f2b = iredutils.get_db_conn(db_name="fail2ban", sql_dbn=__sql_dbn)
else:
web.conn_f2b = None
# Initialize application object.
app = web.application(urls)
session_initializer = {
"webmaster": settings.webmaster,
"username": None,
"logged": False,
# Admin
"is_global_admin": False,
"is_normal_admin": False,
# normal mail user
"account_is_mail_user": False,
"failed_times": 0, # Integer.
"lang": settings.default_language,
# Show used quota.
"show_used_quota": settings.SHOW_USED_QUOTA,
# Amavisd related features.
"amavisd_enable_quarantine": settings.amavisd_enable_quarantine,
"amavisd_enable_logging": settings.amavisd_enable_logging,
"amavisd_enable_policy_lookup": settings.amavisd_enable_policy_lookup,
# iRedAPD related features.
"iredapd_enabled": settings.iredapd_enabled,
"fail2ban_enabled": settings.fail2ban_enabled,
}
session = web.session.Session(
app=app,
store=web.session.DBStore(conn_iredadmin, "sessions"),
initializer=session_initializer,
)
web.config._session = session
# Generate CSRF token and store it in session.
def csrf_token():
if "csrf_token" not in session:
session["csrf_token"] = iredutils.generate_random_strings(32)
return session["csrf_token"]
jinja_env_vars = {
# Set global variables for Jinja2 template
"_": iredutils.ired_gettext, # Override _() which provided by Jinja2.
"ctx": web.ctx, # Used to get 'homepath'.
"skin": settings.SKIN,
"session": web.config._session,
"backend": settings.backend,
"csrf_token": csrf_token,
"page_size_limit": settings.PAGE_SIZE_LIMIT,
"url_support": settings.URL_SUPPORT,
# newsletter (mlmmj mailing list)
"newsletter_base_url": settings.NEWSLETTER_BASE_URL,
# Brand logo, name, description
"brand_logo": settings.BRAND_LOGO,
"brand_name": settings.BRAND_NAME,
"brand_desc": settings.BRAND_DESC,
"brand_favicon": settings.BRAND_FAVICON,
}
jinja_env_filters = {
"file_size_format": jinja_filters.file_size_format,
"cut_string": jinja_filters.cut_string,
"convert_to_percentage": jinja_filters.convert_to_percentage,
"epoch_seconds_to_gmt": iredutils.epoch_seconds_to_gmt,
"epoch_days_to_date": iredutils.epoch_days_to_date,
"set_datetime_format": iredutils.set_datetime_format,
"generate_random_password": iredpwd.generate_random_password,
"utc_to_timezone": ireddate.utc_to_timezone,
}
_default_template_dir = rootdir + "/../templates/" + settings.SKIN
# Define template renders.
def render_template(template_name, **kwargs):
jinja_env = Environment(
loader=FileSystemLoader(_default_template_dir),
extensions=["jinja2.ext.do"],
)
jinja_env.globals.update(jinja_env_vars)
jinja_env.filters.update(jinja_env_filters)
web.header("Content-Type", "text/html")
return jinja_env.get_template(template_name).render(kwargs)
class SessionExpired(web.HTTPError):
def __init__(self, message):
try:
# Expire the cookie. Fixed in webpy master branch on Sep 21, 2020.
cookie_name = web.config.session_parameters['cookie_name']
web.setcookie(cookie_name, session.session_id, expires=-1)
session.kill()
except:
pass
message = web.seeother("/login?msg=SESSION_EXPIRED")
web.HTTPError.__init__(self, "303 See Other", {}, data=message)
# Load hooks
app.add_processor(web.loadhook(hooks.hook_set_language))
if settings.DEBUG:
app.internalerror = web.debugerror
elif settings.MAIL_ERROR_TO_WEBMASTER:
# Mail 500 error to webmaster.
app.internalerror = web.emailerrors(settings.webmaster, web.webapi._InternalError)
# Store objects in 'web' module.
web.render = render_template
web.session.SessionExpired = SessionExpired

200
libs/ireddate.py Normal file
View File

@@ -0,0 +1,200 @@
import time
import re
from datetime import tzinfo, timedelta, datetime
from libs.l10n import TIMEZONE_OFFSETS
import settings
__timezone__ = None
__local_timezone__ = None
__timezones__ = {}
DEFAULT_DATETIME_INPUT_FORMATS = (
"%Y-%m-%d %H:%M:%S", # '2006-10-25 14:30:59'
"%Y-%m-%d %H:%M", # '2006-10-25 14:30'
"%Y-%m-%d", # '2006-10-25'
"%Y/%m/%d %H:%M:%S", # '2006/10/25 14:30:59'
"%Y/%m/%d %H:%M", # '2006/10/25 14:30'
"%Y/%m/%d ", # '2006/10/25 '
"%m/%d/%Y %H:%M:%S", # '10/25/2006 14:30:59'
"%m/%d/%Y %H:%M", # '10/25/2006 14:30'
"%m/%d/%Y", # '10/25/2006'
"%m/%d/%y %H:%M:%S", # '10/25/06 14:30:59'
"%m/%d/%y %H:%M", # '10/25/06 14:30'
"%m/%d/%y", # '10/25/06'
"%H:%M:%S", # '14:30:59'
"%H:%M", # '14:30'
)
ZERO = timedelta(0)
class UTCTimeZone(tzinfo):
"""UTC"""
def utcoffset(self, dt):
return ZERO
def tzname(self, dt):
return "UTC"
def dst(self, dt):
return ZERO
def __repr__(self):
return "<tzinfo UTC>"
UTC = UTCTimeZone()
class FixedOffset(tzinfo):
"""Fixed offset in minutes east from UTC."""
def __init__(self, offset, name):
self.__offset = timedelta(minutes=offset)
self.__name = name
def utcoffset(self, dt):
return self.__offset
def tzname(self, dt):
return self.__name
def dst(self, dt):
return ZERO
for (tzname, offset) in list(TIMEZONE_OFFSETS.items()):
__timezones__[tzname] = FixedOffset(offset, tzname)
re_timezone = re.compile(r"GMT\s?([+-]?)(\d+):(\d\d)", re.IGNORECASE)
def fix_gmt_timezone(tz):
if isinstance(tz, str):
b = re_timezone.match(tz)
if b:
sign = b.group(1)
if not sign:
sign = "+"
hour = b.group(2)
if hour in ["0", "00"]:
return "UTC"
minute = b.group(3)
return "GMT" + sign + hour + ":" + minute
return tz
def set_local_timezone(tz):
global __local_timezone__
__local_timezone__ = timezone(tz)
def get_local_timezone():
return __local_timezone__
def timezone(tzname):
# Validate tzname and return it
if not tzname:
return None
if isinstance(tzname, str):
# not pytz module imported, so just return None
tzname = fix_gmt_timezone(tzname)
tz = __timezones__.get(tzname, None)
if not tz:
tz = UTC
return tz
elif isinstance(tzname, tzinfo):
return tzname
else:
return UTC
def pick_timezone(*args):
for x in args:
tz = timezone(x)
if tz:
return tz
def to_timezone(dt, tzinfo=None):
"""
Convert a datetime to timezone
"""
if not dt:
return dt
tz = pick_timezone(tzinfo, __timezone__)
if not tz:
return dt
dttz = getattr(dt, "tzinfo", None)
if not dttz:
return dt.replace(tzinfo=tz)
else:
return dt.astimezone(tz)
def to_datetime_with_tzinfo(dt, tzinfo=None, formatstr=None):
"""
Convert a date or time to datetime with tzinfo
"""
if not dt:
return dt
tz = pick_timezone(tzinfo, __timezone__)
if isinstance(dt, str):
if not formatstr:
formats = DEFAULT_DATETIME_INPUT_FORMATS
else:
formats = list(formatstr)
d = None
for fmt in formats:
try:
d = datetime(*time.strptime(dt, fmt)[:6])
except ValueError:
continue
if not d:
return None
d = d.replace(tzinfo=tz)
else:
d = datetime(
getattr(dt, "year", 1970),
getattr(dt, "month", 1),
getattr(dt, "day", 1),
getattr(dt, "hour", 0),
getattr(dt, "minute", 0),
getattr(dt, "second", 0),
getattr(dt, "microsecond", 0),
)
if not getattr(dt, "tzinfo", None):
d = d.replace(tzinfo=tz)
else:
d = d.replace(tzinfo=dt.tzinfo)
return to_timezone(d, tzinfo)
def utc_to_timezone(dt, timezone=None, format="%Y-%m-%d %H:%M:%S"):
if not timezone:
timezone = settings.LOCAL_TIMEZONE
# Convert original timestamp to new timestamp with UTC timezone.
t = to_datetime_with_tzinfo(dt, tzinfo=UTC)
# Convert original timestamp (with UTC timezone) to timestamp with
# local timezone.
ft = to_datetime_with_tzinfo(t, tzinfo=timezone)
if ft:
# Check 'daylight saving time'
if time.localtime().tm_isdst:
ft += timedelta(seconds=3600)
return ft.strftime(format)
else:
return "--"

567
libs/iredpwd.py Normal file
View File

@@ -0,0 +1,567 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import crypt
import hashlib
import random
import string
import subprocess
from base64 import b64encode, b64decode
from hmac import compare_digest
from os import urandom
from typing import Union, List
import settings
from libs import iredutils
def __has_non_ascii_character(s):
"""
Detect whether a string contains non-ascii character or not.
integer ordinal of a one-character string between 32 and 126 are digits,
letters, punctuation.
Reference: http://docs.python.org/2/library/string.html#string.printable
"""
for i in s:
try:
if not (32 <= ord(i) <= 126):
return True
except TypeError:
# ord() will raise TypeError for non-ascii character
return True
return False
def verify_new_password(
newpw, confirmpw, min_passwd_length=None, max_passwd_length=None, db_settings=None
):
# Confirm password
if newpw == confirmpw:
passwd = newpw
else:
return False, "PW_MISMATCH"
# Empty password is not allowed.
if not passwd:
return False, "PW_EMPTY"
errors = []
# Non-ascii character is not allowed
if __has_non_ascii_character(passwd):
errors.append("PW_NON_ASCII")
# Get settings from db
if not db_settings:
params = [
"min_passwd_length",
"max_passwd_length",
"password_has_letter",
"password_has_uppercase",
"password_has_number",
"password_has_special_char",
]
db_settings = iredutils.get_settings_from_db(params=params)
# Get and verify password length
if not min_passwd_length or not isinstance(min_passwd_length, int):
min_passwd_length = db_settings["min_passwd_length"]
if not max_passwd_length or not isinstance(max_passwd_length, int):
max_passwd_length = db_settings["max_passwd_length"]
if len(passwd) < min_passwd_length:
errors.append("PW_SHORTER_THAN_MIN_LENGTH")
if max_passwd_length > 0:
if len(passwd) > max_passwd_length:
errors.append("PW_GREATER_THAN_MAX_LENGTH")
# Password restriction rules
if db_settings["password_has_letter"]:
if not (set(passwd) & set(string.ascii_letters)):
errors.append("PW_NO_LETTER")
if db_settings["password_has_uppercase"]:
if not (set(passwd) & set(string.ascii_uppercase)):
errors.append("PW_NO_UPPERCASE")
if db_settings["password_has_number"]:
if not (set(passwd) & set(string.digits)):
errors.append("PW_NO_DIGIT_NUMBER")
if db_settings["password_has_special_char"]:
if not (set(passwd) & set(settings.PASSWORD_SPECIAL_CHARACTERS)):
errors.append("PW_NO_SPECIAL_CHAR")
if errors:
return False, ",".join(errors)
else:
return True, passwd
def generate_random_password(length=10, db_settings=None):
try:
length = int(length)
if length <= 0:
length = 10
elif length <= 10:
# We should always suggest a strong password
length = 10
except:
length = 10
if not db_settings:
params = [
"min_passwd_length",
"max_passwd_length",
"password_has_letter",
"password_has_uppercase",
"password_has_number",
"password_has_special_char",
]
db_settings = iredutils.get_settings_from_db(account="global", params=params)
if length < db_settings["min_passwd_length"]:
length = db_settings["min_passwd_length"]
numbers = "23456789" # No 0, 1
letters = "abcdefghjkmnpqrstuvwxyz" # no i, l
uppercases = "ABCDEFGHJKLMNPQRSTUVWXYZ" # no I
opts = []
if db_settings["password_has_letter"]:
opts += random.choice(letters)
length -= 1
if db_settings["password_has_uppercase"]:
opts += random.choice(uppercases)
length -= 1
if db_settings["password_has_number"]:
opts += random.choice(numbers)
length -= 1
if (
db_settings["password_has_special_char"]
and settings.PASSWORD_SPECIAL_CHARACTERS
):
# Don't use few characters which may mess HTML render.
while True:
char = random.choice(settings.PASSWORD_SPECIAL_CHARACTERS)
if char in ["'", '"', ">", "<"]:
continue
else:
opts += char
length -= 1
break
opts += list(iredutils.generate_random_strings(length))
password = ""
for _ in range(len(opts)):
one = random.choice(opts)
password += one
opts.remove(one)
return password
def generate_bcrypt_password(p) -> str:
if isinstance(p, str):
p = p.encode()
try:
import bcrypt
except:
return generate_ssha_password(p)
return "{CRYPT}" + bcrypt.hashpw(p, bcrypt.gensalt()).decode()
def verify_bcrypt_password(challenge_password: str, plain_password: str) -> bool:
try:
import bcrypt
except:
return False
crypt_suffixes = ("{CRYPT}$2a$", "{CRYPT}$2b$",
"{crypt}$2a$", "{crypt}$2b$")
blf_crypt_suffixes = ("{BLF-CRYPT}", "{blf-crypt}")
if challenge_password.startswith(crypt_suffixes):
challenge_password = challenge_password[7:]
elif challenge_password.startswith(blf_crypt_suffixes):
challenge_password = challenge_password[11:]
return bcrypt.checkpw(plain_password.encode(), challenge_password.encode())
def generate_md5_password(p: str) -> str:
return crypt.crypt(p, salt=crypt.METHOD_MD5)
def verify_md5_password(challenge_password: Union[str, bytes],
plain_password: str) -> bool:
"""Verify salted MD5 password"""
if isinstance(challenge_password, bytes):
challenge_password = challenge_password.decode()
if challenge_password.startswith(("{MD5}", "{md5}")):
challenge_password = challenge_password[5:]
elif challenge_password.startswith(("{CRYPT}", "{crypt}")):
challenge_password = challenge_password[7:]
if not (challenge_password.startswith("$")
and len(challenge_password) == 34
and challenge_password.count("$") == 3):
return False
return compare_digest(challenge_password,
crypt.crypt(plain_password, challenge_password))
def generate_plain_md5_password(p: Union[str, bytes]) -> str:
if isinstance(p, str):
p = p.encode()
p = p.strip()
return hashlib.md5(p).hexdigest()
def verify_plain_md5_password(challenge_password, plain_password):
if challenge_password.startswith(("{PLAIN-MD5}", "{plain-md5}")):
challenge_password = challenge_password[11:]
if challenge_password == generate_plain_md5_password(plain_password):
return True
else:
return False
def generate_ssha_password(p: Union[str, bytes]) -> str:
if isinstance(p, str):
p = p.encode()
p = p.strip()
salt = urandom(8)
pw = hashlib.sha1(p)
pw.update(salt)
return "{SSHA}" + b64encode(pw.digest() + salt).decode()
def verify_ssha_password(challenge_password: Union[str, bytes],
plain_password: Union[str, bytes]) -> bool:
"""Verify SHA or SSHA (salted SHA) hash with or without prefix {SHA}, {SSHA}"""
if isinstance(challenge_password, bytes):
challenge_password = challenge_password.decode()
if isinstance(plain_password, str):
plain_password = plain_password.encode()
if challenge_password.startswith(("{SSHA}", "{ssha}")):
challenge_password = challenge_password[6:]
elif challenge_password.startswith(("{SHA}", "{sha}")):
challenge_password = challenge_password[5:]
if len(challenge_password) < 20:
# Not a valid SSHA hash
return False
try:
challenge_bytes = b64decode(challenge_password)
digest = challenge_bytes[:20]
salt = challenge_bytes[20:]
hr = hashlib.sha1(plain_password)
hr.update(salt)
return digest == hr.digest()
except:
return False
def generate_sha512_password(p: Union[str, bytes]) -> str:
"""Generate SHA512 password with prefix '{SHA512}'."""
if isinstance(p, str):
p = p.encode()
p = p.strip()
pw = hashlib.sha512(p)
return "{SHA512}" + b64encode(pw.digest()).decode()
def verify_sha512_password(challenge_password: Union[str, bytes],
plain_password: Union[str, bytes]) -> bool:
"""Verify SHA512 password with or without prefix '{SHA512}'."""
if isinstance(challenge_password, bytes):
challenge_password = challenge_password.decode()
if isinstance(plain_password, str):
plain_password = plain_password.encode()
if challenge_password.startswith(("{SHA512}", "{sha512}")):
challenge_password = challenge_password[8:]
if len(challenge_password) != 88:
return False
try:
challenge_bytes = b64decode(challenge_password)
digest = challenge_bytes[:64]
hr = hashlib.sha512(plain_password)
return digest == hr.digest() # bytes == bytes
except:
return False
def verify_sha512_crypt_password(challenge_password: Union[str, bytes],
plain_password: str) -> bool:
"""Verify SHA512 password with prefix '{SHA512-CRYPT}'."""
if not challenge_password.startswith(("{SHA512-CRYPT}", "{sha512-crypt}")):
return False
challenge_password = challenge_password[14:]
return compare_digest(challenge_password,
crypt.crypt(plain_password, challenge_password))
def generate_ssha512_password(p: Union[str, bytes]) -> str:
"""Generate salted SHA512 password with prefix '{SSHA512}'."""
if isinstance(p, str):
p = p.encode()
p = p.strip()
salt = urandom(8)
pw = hashlib.sha512(p)
pw.update(salt)
return "{SSHA512}" + b64encode(pw.digest() + salt).decode()
def verify_ssha512_password(challenge_password: Union[str, bytes],
plain_password: Union[str, bytes]) -> bool:
"""Verify SSHA512 password with or without prefix '{SSHA512}'."""
if isinstance(challenge_password, bytes):
challenge_password = challenge_password.decode()
if isinstance(plain_password, str):
plain_password = plain_password.encode()
if challenge_password.startswith(("{SSHA512}", "{ssha512}")):
challenge_password = challenge_password[9:]
# With SSHA512, hash itself is 64 bytes (512 bits/8 bits per byte),
# everything after that 64 bytes is the salt.
if len(challenge_password) < 64:
return False
try:
challenge_bytes = b64decode(challenge_password)
digest = challenge_bytes[:64]
salt = challenge_bytes[64:]
hr = hashlib.sha512(plain_password)
hr.update(salt)
return digest == hr.digest()
except:
return False
def generate_password_with_doveadmpw(scheme: str, plain_password: str) -> str:
"""Generate password hash with `doveadm pw` command.
Return SSHA instead if no 'doveadm' command found or other error raised."""
# scheme: CRAM-MD5, NTLM
scheme = scheme.upper()
p = str(plain_password).strip()
try:
pp = subprocess.Popen(
args=["doveadm", "pw", "-s", scheme, "-p", p],
stdout=subprocess.PIPE
)
pw = pp.communicate()[0].decode()
if scheme in settings.HASHES_WITHOUT_PREFIXED_PASSWORD_SCHEME:
pw = pw.lstrip("{" + scheme + "}")
# remove '\n'
pw = pw.strip()
return pw
except:
return generate_ssha_password(p)
def verify_password_with_doveadmpw(challenge_password: Union[str, bytes],
plain_password: str) -> bool:
"""Verify password hash with `doveadm pw` command."""
if isinstance(challenge_password, bytes):
challenge_password = challenge_password.decode()
try:
cmd = [
"doveadm",
"pw",
"-t",
challenge_password.strip(),
"-p",
plain_password.strip(),
]
_return_code = subprocess.call(cmd, stdin=None, stdout=None, stderr=None, shell=False)
if _return_code == 0:
return True
except:
pass
return False
def generate_cram_md5_password(p):
return generate_password_with_doveadmpw("CRAM-MD5", p)
def verify_cram_md5_password(challenge_password, plain_password):
"""Verify CRAM-MD5 hash with 'doveadm pw' command."""
if not challenge_password.lower().strip().startswith("{cram-md5}"):
return False
return verify_password_with_doveadmpw(challenge_password, plain_password)
def generate_ntlm_password(p):
return generate_password_with_doveadmpw("NTLM", p)
def verify_ntlm_password(challenge_password, plain_password):
"""Verify NTLM hash with 'doveadm pw' command."""
if "NTLM" not in settings.HASHES_WITHOUT_PREFIXED_PASSWORD_SCHEME:
if not challenge_password.startswith(("{NTLM}", "{ntlm}")):
# Prefix '{NTLM}' so that doveadm can verify it.
challenge_password = "{NTLM}" + challenge_password
else:
if not challenge_password.startswith(("{NTLM}", "{ntlm}")):
return False
return verify_password_with_doveadmpw(challenge_password, plain_password)
def generate_password_hash(p: Union[str, bytes],
pwscheme: str = None) -> Union[str, List[str]]:
"""Generate password for LDAP mail user and admin."""
if isinstance(p, bytes):
p = p.decode()
p = p.strip()
pwscheme = pwscheme or settings.DEFAULT_PASSWORD_SCHEME
# Supports returning multiple passwords.
pw_schemes = pwscheme.split("+")
pws = []
for scheme in pw_schemes:
if scheme == "BCRYPT":
pw_hash = generate_bcrypt_password(p)
elif scheme == "SSHA512":
pw_hash = generate_ssha512_password(p)
elif scheme == "SHA512":
pw_hash = generate_sha512_password(p)
elif scheme == "SSHA":
pw_hash = generate_ssha_password(p)
elif scheme == "MD5":
pw_hash = "{CRYPT}" + generate_md5_password(p)
elif scheme == "CRAM-MD5":
pw_hash = generate_cram_md5_password(p)
elif scheme == "PLAIN-MD5":
pw_hash = generate_plain_md5_password(p)
elif scheme == "NTLM":
pw_hash = generate_ntlm_password(p)
elif scheme == "PLAIN":
if "PLAIN" in settings.HASHES_WITHOUT_PREFIXED_PASSWORD_SCHEME:
pw_hash = p
else:
pw_hash = "{PLAIN}" + p
else:
pw_hash = p
pws.append(pw_hash)
if len(pws) == 1:
return pws[0]
else:
return pws
def verify_password_hash(challenge_password: Union[str, bytes],
plain_password: Union[str, bytes]) -> bool:
if isinstance(challenge_password, bytes):
challenge_password = challenge_password.decode()
if isinstance(plain_password, bytes):
plain_password = plain_password.decode()
# Check plain password and MD5 first.
if challenge_password in [
plain_password,
"{PLAIN}" + plain_password,
"{plain}" + plain_password,
]:
return True
elif verify_md5_password(challenge_password, plain_password):
return True
upwd = challenge_password.upper()
if upwd.startswith(("{SSHA}", "{SHA}")):
return verify_ssha_password(challenge_password, plain_password)
elif upwd.startswith("{SSHA512}"):
return verify_ssha512_password(challenge_password, plain_password)
elif upwd.startswith(("{CRYPT}$2A$", "{CRYPT}$2B$", "{BLF-CRYPT}$2A$", "{BLF-CRYPT}$2B$", "{BLF-CRYPT}$2Y$")):
return verify_bcrypt_password(challenge_password, plain_password)
elif upwd.startswith(("{CRYPT}$6$", "{CRYPT}$2B$")):
# CRYPT-SHA-512
return verify_password_with_doveadmpw(challenge_password, plain_password)
elif upwd.startswith("{SHA512}"):
return verify_sha512_password(challenge_password, plain_password)
elif upwd.startswith("{PLAIN-MD5}"):
return verify_plain_md5_password(challenge_password, plain_password)
elif upwd.startswith("{SHA512-CRYPT}"):
return verify_sha512_crypt_password(challenge_password, plain_password)
elif upwd.startswith("{CRAM-MD5}"):
return verify_cram_md5_password(challenge_password, plain_password)
elif upwd.startswith("{NTLM}"):
return verify_ntlm_password(challenge_password, plain_password)
return False
def is_supported_password_scheme(pw_hash):
if not (pw_hash.startswith("{") and "}" in pw_hash):
return False
# Extract scheme name from password hash: "{SSHA}xxxx" -> "SSHA"
try:
scheme = pw_hash.split("}", 1)[0].split("{", 1)[-1]
scheme = scheme.upper()
if scheme in [
"PLAIN",
"CRYPT",
"MD5",
"PLAIN-MD5",
"SHA",
"SSHA",
"SHA512",
"SSHA512",
"SHA512-CRYPT",
"BCRYPT",
"CRAM-MD5",
"NTLM",
]:
return True
except:
pass
return False

1540
libs/iredutils.py Normal file

File diff suppressed because it is too large Load Diff

78
libs/jinja_filters.py Normal file
View File

@@ -0,0 +1,78 @@
"""Custom Jinja2 filters."""
def file_size_format(value, base_mb=False):
"""Convert file size to a human-readable format, e.g. 20 MB, 1 GB, 2 TB.
@value -- file size in KB
@base_mb -- if True, @value is in MB.
"""
ret = "0"
try:
_bytes = float(value)
except:
return ret
if base_mb:
_bytes = _bytes * 1024 * 1024
# byte
base = 1024
if _bytes == 0:
return ret
if _bytes < base:
ret = "%d Bytes" % _bytes
elif _bytes < base * base:
ret = "%d KB" % (_bytes / base)
elif _bytes < base * base * base:
ret = "%d MB" % (_bytes / (base * base))
elif _bytes < base * base * base * base:
if _bytes % (base * base * base) == 0:
ret = "%d GB" % (_bytes / (base * base * base))
else:
ret = "%.2f GB" % (_bytes / (base * base * base))
else:
if _bytes % (base * base * base * base) == 0:
ret = "%d TB" % (_bytes / (base * base * base * base))
else:
ret = "%d GB" % (_bytes / (base * base * base))
return ret
def cut_string(s, length=40):
try:
if len(s) != len(s.encode("utf-8", "replace")):
length = length / 2
if len(s) >= length:
return s[:length] + "..."
else:
return s
except UnicodeDecodeError:
return str(s, encoding="utf-8", errors="replace")
except:
return s
# Return value of percentage.
def convert_to_percentage(current, total):
try:
current = int(current)
total = int(total)
except:
return 0
if current == 0 or total == 0:
return 0
else:
percent = (current * 100) // total
if percent < 0:
return 0
elif percent > 100:
return 100
else:
return percent

531
libs/l10n.py Normal file
View File

@@ -0,0 +1,531 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
langmaps = {
"en_US": "English (US)",
"sq_AL": "Albanian",
"ar_SA": "Arabic",
"hy_AM": "Armenian",
"az_AZ": "Azerbaijani",
"bs_BA": "Bosnian (Serbian Latin)",
"bg_BG": "Bulgarian",
"ca_ES": "Català",
"cy_GB": "Cymraeg",
"hr_HR": "Croatian (Hrvatski)",
"cs_CZ": "Čeština",
"da_DK": "Dansk",
"de_DE": "Deutsch (Deutsch)",
"de_CH": "Deutsch (Schweiz)",
"en_GB": "English (GB)",
"es_ES": "Español",
"eo": "Esperanto",
"et_EE": "Estonian",
"eu_ES": "Euskara (Basque)",
"fi_FI": "Finnish (Suomi)",
"nl_BE": "Flemish",
"fr_FR": "Français",
"gl_ES": "Galego (Galician)",
"ka_GE": "Georgian (Kartuli)",
"el_GR": "Greek",
"he_IL": "Hebrew",
"hi_IN": "Hindi",
"hu_HU": "Hungarian",
"is_IS": "Icelandic",
"id_ID": "Indonesian",
"ga_IE": "Irish",
"it_IT": "Italiano",
"ja_JP": "Japanese (日本語)",
"ko_KR": "Korean",
"ku": "Kurdish (Kurmancî)",
"lv_LV": "Latvian",
"lt_LT": "Lithuanian",
"mk_MK": "Macedonian",
"ms_MY": "Malay",
"nl_NL": "Netherlands",
"ne_NP": "Nepali",
"nb_NO": "Norsk (Bokmål)",
"nn_NO": "Norsk (Nynorsk)",
"fa": "Persian (Farsi)",
"pl_PL": "Polski",
"pt_BR": "Portuguese (Brazilian)",
"pt_PT": "Portuguese (Standard)",
"ro_RO": "Romanian",
"ru_RU": "Русский",
"sr_CS": "Serbian (Cyrillic)",
"sr_LT": "Serbian (Latin)",
"si_LK": "Sinhala",
"sk_SK": "Slovak",
"sl_SI": "Slovenian",
"sv_SE": "Swedish (Svenska)",
"th_TH": "Thai",
"tr_TR": "Türkçe",
"uk_UA": "Ukrainian",
"vi_VN": "Vietnamese",
"zh_CN": "简体中文",
"zh_TW": "繁體中文",
}
# All available timezone names and time offsets (in minutes).
TIMEZONE_OFFSETS = {
"GMT-12:00": -720,
"GMT-11:00": -660,
"GMT-10:00": -600,
"GMT-09:30": -570,
"GMT-09:00": -540,
"GMT-08:00": -480,
"GMT-07:00": -420,
"GMT-06:00": -360,
"GMT-05:00": -300,
"GMT-04:30": -270,
"GMT-04:00": -240,
"GMT-03:30": -210,
"GMT-03:00": -180,
"GMT-02:00": -120,
"GMT-01:00": -60,
"GMT": 0,
"GMT+01:00": 60,
"GMT+02:00": 120,
"GMT+03:00": 180,
"GMT+03:30": 210,
"GMT+04:00": 240,
"GMT+04:30": 270,
"GMT+05:00": 300,
"GMT+05:30": 330,
"GMT+05:45": 345,
"GMT+06:00": 360,
"GMT+06:30": 390,
"GMT+07:00": 420,
"GMT+08:00": 480,
"GMT+08:45": 525,
"GMT+09:00": 540,
"GMT+09:30": 570,
"GMT+10:00": 600,
"GMT+10:30": 630,
"GMT+11:00": 660,
"GMT+11:30": 690,
"GMT+12:00": 720,
"GMT+12:45": 765,
"GMT+13:00": 780,
"GMT+14:00": 840,
}
TIMEZONES = {
"Pacific/Midway": "GMT-11:00",
"Pacific/Niue": "GMT-11:00",
"Pacific/Pago_Pago": "GMT-11:00",
"America/Adak": "GMT-10:00",
"Pacific/Honolulu": "GMT-10:00",
"Pacific/Johnston": "GMT-10:00",
"Pacific/Rarotonga": "GMT-10:00",
"Pacific/Tahiti": "GMT-10:00",
"Pacific/Marquesas": "GMT-09:30",
"America/Anchorage": "GMT-09:00",
"America/Juneau": "GMT-09:00",
"America/Nome": "GMT-09:00",
"America/Sitka": "GMT-09:00",
"America/Yakutat": "GMT-09:00",
"Pacific/Gambier": "GMT-09:00",
"America/Dawson": "GMT-08:00",
"America/Los_Angeles": "GMT-08:00",
"America/Metlakatla": "GMT-08:00",
"America/Santa_Isabel": "GMT-08:00",
"America/Tijuana": "GMT-08:00",
"America/Vancouver": "GMT-08:00",
"America/Whitehorse": "GMT-08:00",
"Pacific/Pitcairn": "GMT-08:00",
"America/Boise": "GMT-07:00",
"America/Cambridge_Bay": "GMT-07:00",
"America/Chihuahua": "GMT-07:00",
"America/Creston": "GMT-07:00",
"America/Dawson_Creek": "GMT-07:00",
"America/Denver": "GMT-07:00",
"America/Edmonton": "GMT-07:00",
"America/Hermosillo": "GMT-07:00",
"America/Inuvik": "GMT-07:00",
"America/Mazatlan": "GMT-07:00",
"America/Ojinaga": "GMT-07:00",
"America/Phoenix": "GMT-07:00",
"America/Yellowknife": "GMT-07:00",
"America/Bahia_Banderas": "GMT-06:00",
"America/Belize": "GMT-06:00",
"America/Chicago": "GMT-06:00",
"America/Costa_Rica": "GMT-06:00",
"America/El_Salvador": "GMT-06:00",
"America/Guatemala": "GMT-06:00",
"America/Indiana/Knox": "GMT-06:00",
"America/Indiana/Tell_City": "GMT-06:00",
"America/Managua": "GMT-06:00",
"America/Matamoros": "GMT-06:00",
"America/Menominee": "GMT-06:00",
"America/Merida": "GMT-06:00",
"America/Mexico_City": "GMT-06:00",
"America/Monterrey": "GMT-06:00",
"America/North_Dakota/Beulah": "GMT-06:00",
"America/North_Dakota/Center": "GMT-06:00",
"America/North_Dakota/New_Salem": "GMT-06:00",
"America/Rainy_River": "GMT-06:00",
"America/Rankin_Inlet": "GMT-06:00",
"America/Regina": "GMT-06:00",
"America/Resolute": "GMT-06:00",
"America/Swift_Current": "GMT-06:00",
"America/Tegucigalpa": "GMT-06:00",
"America/Winnipeg": "GMT-06:00",
"Pacific/Galapagos": "GMT-06:00",
"America/Atikokan": "GMT-05:00",
"America/Bogota": "GMT-05:00",
"America/Cancun": "GMT-05:00",
"America/Cayman": "GMT-05:00",
"America/Detroit": "GMT-05:00",
"America/Eirunepe": "GMT-05:00",
"America/Guayaquil": "GMT-05:00",
"America/Havana": "GMT-05:00",
"America/Indiana/Indianapolis": "GMT-05:00",
"America/Indiana/Marengo": "GMT-05:00",
"America/Indiana/Petersburg": "GMT-05:00",
"America/Indiana/Vevay": "GMT-05:00",
"America/Indiana/Vincennes": "GMT-05:00",
"America/Indiana/Winamac": "GMT-05:00",
"America/Iqaluit": "GMT-05:00",
"America/Jamaica": "GMT-05:00",
"America/Kentucky/Louisville": "GMT-05:00",
"America/Kentucky/Monticello": "GMT-05:00",
"America/Lima": "GMT-05:00",
"America/Nassau": "GMT-05:00",
"America/New_York": "GMT-05:00",
"America/Nipigon": "GMT-05:00",
"America/Panama": "GMT-05:00",
"America/Pangnirtung": "GMT-05:00",
"America/Port-au-Prince": "GMT-05:00",
"America/Rio_Branco": "GMT-05:00",
"America/Thunder_Bay": "GMT-05:00",
"America/Toronto": "GMT-05:00",
"Pacific/Easter": "GMT-05:00",
"America/Caracas": "GMT-04:30",
"America/Anguilla": "GMT-04:00",
"America/Antigua": "GMT-04:00",
"America/Aruba": "GMT-04:00",
"America/Barbados": "GMT-04:00",
"America/Blanc-Sablon": "GMT-04:00",
"America/Boa_Vista": "GMT-04:00",
"America/Curacao": "GMT-04:00",
"America/Dominica": "GMT-04:00",
"America/Glace_Bay": "GMT-04:00",
"America/Goose_Bay": "GMT-04:00",
"America/Grand_Turk": "GMT-04:00",
"America/Grenada": "GMT-04:00",
"America/Guadeloupe": "GMT-04:00",
"America/Guyana": "GMT-04:00",
"America/Halifax": "GMT-04:00",
"America/Kralendijk": "GMT-04:00",
"America/La_Paz": "GMT-04:00",
"America/Lower_Princes": "GMT-04:00",
"America/Manaus": "GMT-04:00",
"America/Marigot": "GMT-04:00",
"America/Martinique": "GMT-04:00",
"America/Moncton": "GMT-04:00",
"America/Montserrat": "GMT-04:00",
"America/Port_of_Spain": "GMT-04:00",
"America/Porto_Velho": "GMT-04:00",
"America/Puerto_Rico": "GMT-04:00",
"America/Santo_Domingo": "GMT-04:00",
"America/St_Barthelemy": "GMT-04:00",
"America/St_Kitts": "GMT-04:00",
"America/St_Lucia": "GMT-04:00",
"America/St_Thomas": "GMT-04:00",
"America/St_Vincent": "GMT-04:00",
"America/Thule": "GMT-04:00",
"America/Tortola": "GMT-04:00",
"Atlantic/Bermuda": "GMT-04:00",
"America/St_Johns": "GMT-03:30",
"America/Araguaina": "GMT-03:00",
"America/Argentina/Buenos_Aires": "GMT-03:00",
"America/Argentina/Catamarca": "GMT-03:00",
"America/Argentina/Cordoba": "GMT-03:00",
"America/Argentina/Jujuy": "GMT-03:00",
"America/Argentina/La_Rioja": "GMT-03:00",
"America/Argentina/Mendoza": "GMT-03:00",
"America/Argentina/Rio_Gallegos": "GMT-03:00",
"America/Argentina/Salta": "GMT-03:00",
"America/Argentina/San_Juan": "GMT-03:00",
"America/Argentina/San_Luis": "GMT-03:00",
"America/Argentina/Tucuman": "GMT-03:00",
"America/Argentina/Ushuaia": "GMT-03:00",
"America/Asuncion": "GMT-03:00",
"America/Bahia": "GMT-03:00",
"America/Belem": "GMT-03:00",
"America/Campo_Grande": "GMT-03:00",
"America/Cayenne": "GMT-03:00",
"America/Cuiaba": "GMT-03:00",
"America/Fortaleza": "GMT-03:00",
"America/Godthab": "GMT-03:00",
"America/Maceio": "GMT-03:00",
"America/Miquelon": "GMT-03:00",
"America/Paramaribo": "GMT-03:00",
"America/Recife": "GMT-03:00",
"America/Santarem": "GMT-03:00",
"America/Santiago": "GMT-03:00",
"Antarctica/Palmer": "GMT-03:00",
"Antarctica/Rothera": "GMT-03:00",
"Atlantic/Stanley": "GMT-03:00",
"America/Montevideo": "GMT-02:00",
"America/Noronha": "GMT-02:00",
"America/Sao_Paulo": "GMT-02:00",
"Atlantic/South_Georgia": "GMT-02:00",
"America/Scoresbysund": "GMT-01:00",
"Atlantic/Azores": "GMT-01:00",
"Atlantic/Cape_Verde": "GMT-01:00",
"Africa/Abidjan": "GMT+00:00",
"Africa/Accra": "GMT+00:00",
"Africa/Bamako": "GMT+00:00",
"Africa/Banjul": "GMT+00:00",
"Africa/Bissau": "GMT+00:00",
"Africa/Casablanca": "GMT+00:00",
"Africa/Conakry": "GMT+00:00",
"Africa/Dakar": "GMT+00:00",
"Africa/El_Aaiun": "GMT+00:00",
"Africa/Freetown": "GMT+00:00",
"Africa/Lome": "GMT+00:00",
"Africa/Monrovia": "GMT+00:00",
"Africa/Nouakchott": "GMT+00:00",
"Africa/Ouagadougou": "GMT+00:00",
"Africa/Sao_Tome": "GMT+00:00",
"America/Danmarkshavn": "GMT+00:00",
"Antarctica/Troll": "GMT+00:00",
"Atlantic/Canary": "GMT+00:00",
"Atlantic/Faroe": "GMT+00:00",
"Atlantic/Madeira": "GMT+00:00",
"Atlantic/Reykjavik": "GMT+00:00",
"Atlantic/St_Helena": "GMT+00:00",
"Europe/Dublin": "GMT+00:00",
"Europe/Guernsey": "GMT+00:00",
"Europe/Isle_of_Man": "GMT+00:00",
"Europe/Jersey": "GMT+00:00",
"Europe/Lisbon": "GMT+00:00",
"Europe/London": "GMT+00:00",
"UTC": "GMT+00:00",
"Africa/Algiers": "GMT+01:00",
"Africa/Bangui": "GMT+01:00",
"Africa/Brazzaville": "GMT+01:00",
"Africa/Ceuta": "GMT+01:00",
"Africa/Douala": "GMT+01:00",
"Africa/Kinshasa": "GMT+01:00",
"Africa/Lagos": "GMT+01:00",
"Africa/Libreville": "GMT+01:00",
"Africa/Luanda": "GMT+01:00",
"Africa/Malabo": "GMT+01:00",
"Africa/Ndjamena": "GMT+01:00",
"Africa/Niamey": "GMT+01:00",
"Africa/Porto-Novo": "GMT+01:00",
"Africa/Tunis": "GMT+01:00",
"Arctic/Longyearbyen": "GMT+01:00",
"Europe/Amsterdam": "GMT+01:00",
"Europe/Andorra": "GMT+01:00",
"Europe/Belgrade": "GMT+01:00",
"Europe/Berlin": "GMT+01:00",
"Europe/Bratislava": "GMT+01:00",
"Europe/Brussels": "GMT+01:00",
"Europe/Budapest": "GMT+01:00",
"Europe/Busingen": "GMT+01:00",
"Europe/Copenhagen": "GMT+01:00",
"Europe/Gibraltar": "GMT+01:00",
"Europe/Ljubljana": "GMT+01:00",
"Europe/Luxembourg": "GMT+01:00",
"Europe/Madrid": "GMT+01:00",
"Europe/Malta": "GMT+01:00",
"Europe/Monaco": "GMT+01:00",
"Europe/Oslo": "GMT+01:00",
"Europe/Paris": "GMT+01:00",
"Europe/Podgorica": "GMT+01:00",
"Europe/Prague": "GMT+01:00",
"Europe/Rome": "GMT+01:00",
"Europe/San_Marino": "GMT+01:00",
"Europe/Sarajevo": "GMT+01:00",
"Europe/Skopje": "GMT+01:00",
"Europe/Stockholm": "GMT+01:00",
"Europe/Tirane": "GMT+01:00",
"Europe/Vaduz": "GMT+01:00",
"Europe/Vatican": "GMT+01:00",
"Europe/Vienna": "GMT+01:00",
"Europe/Warsaw": "GMT+01:00",
"Europe/Zagreb": "GMT+01:00",
"Europe/Zurich": "GMT+01:00",
"Africa/Blantyre": "GMT+02:00",
"Africa/Bujumbura": "GMT+02:00",
"Africa/Cairo": "GMT+02:00",
"Africa/Gaborone": "GMT+02:00",
"Africa/Harare": "GMT+02:00",
"Africa/Johannesburg": "GMT+02:00",
"Africa/Kigali": "GMT+02:00",
"Africa/Lubumbashi": "GMT+02:00",
"Africa/Lusaka": "GMT+02:00",
"Africa/Maputo": "GMT+02:00",
"Africa/Maseru": "GMT+02:00",
"Africa/Mbabane": "GMT+02:00",
"Africa/Tripoli": "GMT+02:00",
"Africa/Windhoek": "GMT+02:00",
"Asia/Amman": "GMT+02:00",
"Asia/Beirut": "GMT+02:00",
"Asia/Damascus": "GMT+02:00",
"Asia/Gaza": "GMT+02:00",
"Asia/Hebron": "GMT+02:00",
"Asia/Jerusalem": "GMT+02:00",
"Asia/Nicosia": "GMT+02:00",
"Europe/Athens": "GMT+02:00",
"Europe/Bucharest": "GMT+02:00",
"Europe/Chisinau": "GMT+02:00",
"Europe/Helsinki": "GMT+02:00",
"Europe/Istanbul": "GMT+03:00",
"Europe/Kaliningrad": "GMT+02:00",
"Europe/Kiev": "GMT+02:00",
"Europe/Mariehamn": "GMT+02:00",
"Europe/Riga": "GMT+02:00",
"Europe/Sofia": "GMT+02:00",
"Europe/Tallinn": "GMT+02:00",
"Europe/Uzhgorod": "GMT+02:00",
"Europe/Vilnius": "GMT+02:00",
"Europe/Zaporozhye": "GMT+02:00",
"Africa/Addis_Ababa": "GMT+03:00",
"Africa/Asmara": "GMT+03:00",
"Africa/Dar_es_Salaam": "GMT+03:00",
"Africa/Djibouti": "GMT+03:00",
"Africa/Juba": "GMT+03:00",
"Africa/Kampala": "GMT+03:00",
"Africa/Khartoum": "GMT+03:00",
"Africa/Mogadishu": "GMT+03:00",
"Africa/Nairobi": "GMT+03:00",
"Antarctica/Syowa": "GMT+03:00",
"Asia/Aden": "GMT+03:00",
"Asia/Baghdad": "GMT+03:00",
"Asia/Bahrain": "GMT+03:00",
"Asia/Kuwait": "GMT+03:00",
"Asia/Qatar": "GMT+03:00",
"Asia/Riyadh": "GMT+03:00",
"Europe/Minsk": "GMT+03:00",
"Europe/Moscow": "GMT+03:00",
"Europe/Simferopol": "GMT+03:00",
"Europe/Volgograd": "GMT+03:00",
"Indian/Antananarivo": "GMT+03:00",
"Indian/Comoro": "GMT+03:00",
"Indian/Mayotte": "GMT+03:00",
"Asia/Tehran": "GMT+03:30",
"Asia/Baku": "GMT+04:00",
"Asia/Dubai": "GMT+04:00",
"Asia/Muscat": "GMT+04:00",
"Asia/Tbilisi": "GMT+04:00",
"Asia/Yerevan": "GMT+04:00",
"Europe/Samara": "GMT+04:00",
"Indian/Mahe": "GMT+04:00",
"Indian/Mauritius": "GMT+04:00",
"Indian/Reunion": "GMT+04:00",
"Asia/Kabul": "GMT+04:30",
"Antarctica/Mawson": "GMT+05:00",
"Asia/Aqtau": "GMT+05:00",
"Asia/Aqtobe": "GMT+05:00",
"Asia/Ashgabat": "GMT+05:00",
"Asia/Dushanbe": "GMT+05:00",
"Asia/Karachi": "GMT+05:00",
"Asia/Oral": "GMT+05:00",
"Asia/Samarkand": "GMT+05:00",
"Asia/Tashkent": "GMT+05:00",
"Asia/Yekaterinburg": "GMT+05:00",
"Indian/Kerguelen": "GMT+05:00",
"Indian/Maldives": "GMT+05:00",
"Asia/Colombo": "GMT+05:30",
"Asia/Kolkata": "GMT+05:30",
"Asia/Kathmandu": "GMT+05:45",
"Antarctica/Vostok": "GMT+06:00",
"Asia/Almaty": "GMT+06:00",
"Asia/Bishkek": "GMT+06:00",
"Asia/Dhaka": "GMT+06:00",
"Asia/Novosibirsk": "GMT+06:00",
"Asia/Omsk": "GMT+06:00",
"Asia/Qyzylorda": "GMT+06:00",
"Asia/Thimphu": "GMT+06:00",
"Asia/Urumqi": "GMT+06:00",
"Indian/Chagos": "GMT+06:00",
"Asia/Rangoon": "GMT+06:30",
"Indian/Cocos": "GMT+06:30",
"Antarctica/Davis": "GMT+07:00",
"Asia/Bangkok": "GMT+07:00",
"Asia/Ho_Chi_Minh": "GMT+07:00",
"Asia/Hovd": "GMT+07:00",
"Asia/Jakarta": "GMT+07:00",
"Asia/Krasnoyarsk": "GMT+07:00",
"Asia/Novokuznetsk": "GMT+07:00",
"Asia/Phnom_Penh": "GMT+07:00",
"Asia/Pontianak": "GMT+07:00",
"Asia/Vientiane": "GMT+07:00",
"Indian/Christmas": "GMT+07:00",
"Antarctica/Casey": "GMT+08:00",
"Asia/Beijing": "GMT+08:00",
"Asia/Brunei": "GMT+08:00",
"Asia/Chita": "GMT+08:00",
"Asia/Choibalsan": "GMT+08:00",
"Asia/Hong_Kong": "GMT+08:00",
"Asia/Irkutsk": "GMT+08:00",
"Asia/Kuala_Lumpur": "GMT+08:00",
"Asia/Kuching": "GMT+08:00",
"Asia/Macau": "GMT+08:00",
"Asia/Makassar": "GMT+08:00",
"Asia/Manila": "GMT+08:00",
"Asia/Shanghai": "GMT+08:00",
"Asia/Singapore": "GMT+08:00",
"Asia/Taipei": "GMT+08:00",
"Asia/Ulaanbaatar": "GMT+08:00",
"Australia/Perth": "GMT+08:00",
"Australia/Eucla": "GMT+08:45",
"Asia/Dili": "GMT+09:00",
"Asia/Jayapura": "GMT+09:00",
"Asia/Khandyga": "GMT+09:00",
"Asia/Pyongyang": "GMT+09:00",
"Asia/Seoul": "GMT+09:00",
"Asia/Tokyo": "GMT+09:00",
"Asia/Yakutsk": "GMT+09:00",
"Pacific/Palau": "GMT+09:00",
"Australia/Darwin": "GMT+09:30",
"Antarctica/DumontDUrville": "GMT+10:00",
"Asia/Magadan": "GMT+10:00",
"Asia/Sakhalin": "GMT+10:00",
"Asia/Ust-Nera": "GMT+10:00",
"Asia/Vladivostok": "GMT+10:00",
"Australia/Brisbane": "GMT+10:00",
"Australia/Lindeman": "GMT+10:00",
"Pacific/Chuuk": "GMT+10:00",
"Pacific/Guam": "GMT+10:00",
"Pacific/Port_Moresby": "GMT+10:00",
"Pacific/Saipan": "GMT+10:00",
"Australia/Sydney": "GMT+10:00",
"Australia/Adelaide": "GMT+10:30",
"Australia/Broken_Hill": "GMT+10:30",
"Antarctica/Macquarie": "GMT+11:00",
"Asia/Srednekolymsk": "GMT+11:00",
"Australia/Currie": "GMT+11:00",
"Australia/Hobart": "GMT+11:00",
"Australia/Lord_Howe": "GMT+11:00",
"Australia/Melbourne": "GMT+11:00",
"Pacific/Bougainville": "GMT+11:00",
"Pacific/Efate": "GMT+11:00",
"Pacific/Guadalcanal": "GMT+11:00",
"Pacific/Kosrae": "GMT+11:00",
"Pacific/Noumea": "GMT+11:00",
"Pacific/Pohnpei": "GMT+11:00",
"Pacific/Norfolk": "GMT+11:30",
"Asia/Anadyr": "GMT+12:00",
"Asia/Kamchatka": "GMT+12:00",
"Pacific/Funafuti": "GMT+12:00",
"Pacific/Kwajalein": "GMT+12:00",
"Pacific/Majuro": "GMT+12:00",
"Pacific/Nauru": "GMT+12:00",
"Pacific/Tarawa": "GMT+12:00",
"Pacific/Wake": "GMT+12:00",
"Pacific/Wallis": "GMT+12:00",
"Antarctica/McMurdo": "GMT+13:00",
"Pacific/Auckland": "GMT+13:00",
"Pacific/Enderbury": "GMT+13:00",
"Pacific/Fakaofo": "GMT+13:00",
"Pacific/Fiji": "GMT+13:00",
"Pacific/Tongatapu": "GMT+13:00",
"Pacific/Chatham": "GMT+13:45",
"Pacific/Apia": "GMT+14:00",
"Pacific/Kiritimati": "GMT+14:00",
}

83
libs/logger.py Normal file
View File

@@ -0,0 +1,83 @@
import sys
import logging
import traceback
from logging.handlers import SysLogHandler
import web
from libs import iredutils
import settings
session = web.config.get("_session")
# Set application name.
logger = logging.getLogger("iredadmin")
# Set log level.
_log_level = getattr(logging, str(settings.LOG_LEVEL).upper())
logger.setLevel(_log_level)
if settings.LOG_TARGET == "stdout":
_handler = logging.StreamHandler()
_formatter = logging.Formatter("%(message)s (%(pathname)s, L%(lineno)d)")
else:
# Defaults to "syslog":
_facility = getattr(SysLogHandler, "LOG_" + settings.SYSLOG_FACILITY.upper())
_formatter = logging.Formatter("%(name)s %(message)s (%(pathname)s, L%(lineno)d)")
if settings.SYSLOG_SERVER.startswith("/"):
# Log to a local socket
_handler = SysLogHandler(address=settings.SYSLOG_SERVER, facility=_facility)
else:
# Log to a network address
_server = (settings.SYSLOG_SERVER, settings.SYSLOG_PORT)
_handler = SysLogHandler(address=_server, facility=_facility)
_handler.setFormatter(_formatter)
logger.addHandler(_handler)
def log_traceback():
exc_type, exc_value, exc_traceback = sys.exc_info()
msg = traceback.format_exception(exc_type, exc_value, exc_traceback)
logger.error(msg)
def log_activity(msg, admin="", domain="", username="", event="", loglevel="info"):
try:
if not admin:
admin = session.get("username")
if username and not domain:
domain = username.split("@", 1)[-1]
msg = str(msg)
# Prepend '[API]' in log message
try:
if web.ctx.fullpath.startswith("/api/"):
msg = "[API] " + msg
except:
pass
web.conn_iredadmin.insert(
"log",
admin=str(admin),
domain=str(domain),
username=str(username),
loglevel=str(loglevel),
event=str(event),
msg=msg,
ip=web.ctx.ip,
timestamp=iredutils.get_gmttime(),
)
if loglevel == "info":
logger.info("{0} admin={1}, domain={2}, username={3}, event={4}, "
"ip={5}".format(msg, admin, domain, username, event, web.ctx.ip))
elif loglevel == "error":
logger.error("{0} admin={1}, domain={2}, username={3}, event={4}, "
"ip={5}".format(msg, admin, domain, username, event, web.ctx.ip))
except:
pass
return None

72
libs/mailparser.py Normal file
View File

@@ -0,0 +1,72 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import email
from email.header import decode_header
from libs.logger import log_traceback
from libs import iredutils
def parse_raw_message(msg: bytes):
"""Read RAW message from string. Return a tuple with 3 elements:
- `headers`: a list of tuple with mail header. [(hdr, value), (hdr, value), ...]
- `bodies`: a list of body parts: [part1, part2, ...]
- `attachments`: a list of attachment file names: [name1, name2, ...]
"""
# Get all mail headers. Sample:
# [('From', 'sender@xx.com'), ('To', 'recipient@xx.net')]
headers = []
# Get decoded content parts of mail body.
bodies = []
# Get list of attachment names.
attachments = []
msg = email.message_from_bytes(msg)
# Extract all headers.
for (header, value) in msg.items():
for (text, encoding) in decode_header(value):
if encoding:
if isinstance(text, bytes):
try:
value = iredutils.bytes2str(text)
except:
pass
headers.append((header, value))
for part in msg.walk():
_content_type = part.get_content_maintype()
# multipart/* is just a container
if _content_type == 'multipart':
continue
# either a string or None.
_filename = part.get_filename()
if _filename:
attachments += [_filename]
if _content_type == 'text':
# Plain text, not an attachment.
try:
if part.get_content_charset():
encoding = part.get_content_charset()
elif part.get_charset():
encoding = part.get_charset()
else:
encoding = 'utf-8'
text = str(part.get_payload(decode=True),
encoding=encoding,
errors='replace')
text = text.strip()
bodies.append(text)
except:
log_traceback()
return headers, bodies, attachments

363
libs/mlmmj/__init__.py Normal file
View File

@@ -0,0 +1,363 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
# Functions used to interactive with mlmmjadmin RESTful API server:
# https://bitbucket.org/iredmail/mlmmjadmin/src
import uuid
import requests
from libs import iredutils
from urllib.parse import urlencode
import settings
api_headers = {settings.MLMMJADMIN_API_AUTH_HEADER: settings.mlmmjadmin_api_auth_token}
base_url = settings.MLMMJADMIN_API_BASE_URL
_verify_ssl = settings.MLMMJADMIN_API_VERIFY_SSL
def __get(mail, params=None):
"""
Send a http GET to mlmmjadmin RESTful API server.
:param mail: mail address of mailing list account.
"""
url = base_url + "/" + mail
try:
r = requests.get(url, headers=api_headers, params=params, verify=_verify_ssl)
return r.json()
except requests.ConnectionError:
return {"_success": False, "_msg": "API_SERVER_NOT_REACHABLE"}
except Exception as e:
return {"_success": False, "_msg": repr(e)}
def __post(mail, data=None):
"""
Send a http POST to mlmmjadmin RESTful API server.
:param mail: mail address of mailing list account.
:param data: a dict used to be sent to mlmmjadmin API server.
"""
url = base_url + "/" + mail
try:
r = requests.post(url, data=data, headers=api_headers, verify=_verify_ssl)
return r.json()
except requests.ConnectionError:
return {"_success": False, "_msg": "API_SERVER_NOT_REACHABLE"}
except Exception as e:
return {"_success": False, "_msg": repr(e)}
def __put(mail, data=None):
"""
Send a http PUT to mlmmjadmin RESTful API server.
:param mail: mail address of mailing list account.
:param data: a dict used to be sent to mlmmjadmin API server.
"""
url = base_url + "/" + mail
try:
r = requests.put(url, data=data, headers=api_headers, verify=_verify_ssl)
return r.json()
except requests.ConnectionError:
return {"_success": False, "_msg": "API_SERVER_NOT_REACHABLE"}
except Exception as e:
return {"_success": False, "_msg": repr(e)}
def __delete(mail, data=None):
"""
Send a http DELETE to mlmmjadmin RESTful API server.
:param mail: mail address of mailing list account.
:param data: a dict used to be encoded as URL parameters and sent to
mlmmjadmin API server.
"""
url = base_url + "/" + mail
if data:
url = url + "?" + urlencode(data)
try:
r = requests.delete(url, headers=api_headers, verify=_verify_ssl)
return r.json()
except requests.ConnectionError:
return {"_success": False, "_msg": "API_SERVER_NOT_REACHABLE"}
except Exception as e:
return {"_success": False, "_msg": repr(e)}
def __get_subscribers(mail, email_only=False):
url = base_url + "/%s/subscribers" % mail
params = {}
if email_only:
params["email_only"] = "yes"
try:
r = requests.get(url, params=params, headers=api_headers, verify=_verify_ssl)
return r.json()
except requests.ConnectionError:
return {"_success": False, "_msg": "API_SERVER_NOT_REACHABLE"}
except Exception as e:
return {"_success": False, "_msg": repr(e)}
def generate_transport(mail):
(listname, domain) = str(mail).lower().split("@", 1)
transport = "{}:{}/{}".format(settings.MLMMJ_MTA_TRANSPORT_NAME, domain, listname)
return transport
def generate_mlid():
"""Generate an server-wide unique uuid as mailing list id."""
return str(uuid.uuid4())
def create_account(mail, form):
"""
Create a mlmmj account by sending a HTTP POST to mlmmjadmin RESTful API.
Arguments:
:param mail: full email address of mailing list account
:param form: form submitted by a web page
"""
mail = str(mail).lower()
if not iredutils.is_email(mail):
return False, "INVALID_EMAIL"
qr = __post(mail=mail, data=form)
if qr["_success"]:
return True,
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def get_account_profile(mail, with_subscribers=False):
"""
Send a HTTP GET to get mailing list profile.
Arguments:
@mail - full email address of mailing list account
"""
mail = str(mail).lower()
if not iredutils.is_email(mail):
return False, "INVALID_EMAIL"
qr = __get(mail=mail)
if qr["_success"]:
profile = qr["_data"]
if with_subscribers:
_qr = __get_subscribers(mail=mail, email_only=True)
if _qr["_success"]:
profile["subscribers"] = _qr["_data"]
return True, profile
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def update_account_profile(mail, data):
"""
Send a HTTP PUT to mlmmjadmin RESTful API.
Arguments:
@mail - full email address of mailing list account
@data - a dict of parameter/value pairs.
"""
mail = str(mail).lower()
if not iredutils.is_email(mail):
return False, "INVALID_EMAIL"
qr = __put(mail=mail, data=data)
if qr["_success"]:
return True,
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def delete_account(mail, keep_archive=True):
"""
Send a HTTP DELETE to mlmmjadmin RESTful API.
Arguments:
@mail - full email address of mailing list account
@keep_archive - archive the account or not
"""
mail = str(mail).lower()
if not iredutils.is_email(mail):
return False, "INVALID_EMAIL"
params = {"archive": "yes"}
if not keep_archive:
params["archive"] = "no"
qr = __delete(mail=mail, data=params)
if qr["_success"]:
return True,
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def delete_accounts(mails, keep_archive=True):
mails = [str(i).lower() for i in mails if iredutils.is_email(i)]
if not mails:
return True,
for i in mails:
qr = delete_account(mail=i, keep_archive=keep_archive)
if not qr[0]:
return qr[0], i + "-" + qr[1]
return True,
def get_subscribers(mail, email_only=False):
mail = str(mail).lower()
if not iredutils.is_email(mail):
return False, "INVALID_EMAIL"
qr = __get_subscribers(mail=mail, email_only=email_only)
if qr["_success"]:
subscribers = qr.get("_data", [])
return True, subscribers
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def add_subscribers(mail, subscribers, subscription="normal", require_confirm=False):
mail = str(mail).lower()
if not iredutils.is_email(mail):
return False, "INVALID_EMAIL"
if subscription not in ["normal", "digest", "nomail"]:
subscription = "normal"
url = base_url + "/%s/subscribers" % mail
params = {"add_subscribers": ",".join(subscribers), "subscription": subscription}
if require_confirm in [True, "yes"]:
params["require_confirm"] = "yes"
r = requests.post(url, data=params, headers=api_headers, verify=_verify_ssl)
qr = r.json()
if qr["_success"]:
return True,
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def remove_subscribers(mail, subscribers):
"""Remove subscribers from mailing list.
:param mail: mail address of mailing list account
:param subscribers: a list/tuple/set of subscribers' mail addresses
"""
mail = str(mail).lower()
if not iredutils.is_email(mail):
return False, "INVALID_EMAIL"
if subscribers:
subscribers = [i.lower() for i in subscribers]
else:
subscribers = []
url = base_url + "/%s/subscribers" % mail
params = {"remove_subscribers": ",".join(subscribers)}
r = requests.post(url, data=params, headers=api_headers, verify=_verify_ssl)
qr = r.json()
if qr["_success"]:
return True,
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def remove_all_subscribers(mail):
"""Remove all subscribers from mailing list.
:param mail: mail address of mailing list account
"""
mail = str(mail).lower()
if not iredutils.is_email(mail):
return False, "INVALID_EMAIL"
url = base_url + "/%s/subscribers" % mail
params = {"remove_subscribers": "ALL"}
r = requests.post(url, data=params, headers=api_headers, verify=_verify_ssl)
qr = r.json()
if qr["_success"]:
return True,
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def subscribe_to_lists(subscriber, lists):
"""Subscribe one mail address to multiple lists."""
subscriber = str(subscriber).lower()
lists = [str(i).lower() for i in lists if iredutils.is_email(i)]
if not lists:
return True,
url = base_url + "/subscriber/%s/subscribe" % subscriber
params = {"lists": ",".join(lists), "require_confirm": "no"}
r = requests.post(url, data=params, headers=api_headers, verify=_verify_ssl)
qr = r.json()
if qr["_success"]:
return True,
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def get_subscribed_lists(mail, query_all_lists=False, email_only=False):
mail = str(mail).lower()
url = base_url + "/subscriber/%s/subscribed" % mail
params = {"query_all_lists": "no", "email_only": "no"}
if query_all_lists:
params["query_all_lists"] = "yes"
if email_only:
params["email_only"] = "yes"
r = requests.get(url, params=params, headers=api_headers, verify=_verify_ssl)
qr = r.json()
if qr["_success"]:
return True, qr["_data"]
else:
return False, qr.get("_msg", "UNKNOWN_ERROR")
def remove_subscriber_from_all_subscribed_lists(subscriber):
"""Remove one subscriber from all subscribed lists under same domain."""
if not iredutils.is_email(subscriber):
return False, "INVALID_EMAIL"
qr = get_subscribed_lists(mail=subscriber, email_only=True)
if not qr[0]:
return qr
_lists = qr[1]
_errors = []
for ml in _lists:
_qr = remove_subscribers(mail=ml, subscribers=[subscriber])
if not _qr[0]:
_errors += ["{}: {}".format(subscriber, repr(_qr[1]))]
if _errors:
return False, " ".join(_errors)
else:
return True,

19
libs/panel/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
# Events in admin log. Detailed comments of event names are defined in
# templates/default/macros/general.html
LOG_EVENTS = [
'all',
'login',
'user_login',
'active',
'disable',
'create',
'delete',
'update',
'grant', # Grant user as domain admin
'revoke', # Revoke admin privilege
'backup',
'delete_mailboxes',
'update_wblist',
'iredapd', # iRedAPD rejection.
'unban', # Unban IP address
]

View File

@@ -0,0 +1,429 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import time
from dns import resolver
import requests
import web
import settings
from libs import iredutils
if settings.backend == 'ldap':
from libs.ldaplib.admin import get_managed_domains
else:
from libs.sqllib.admin import get_managed_domains
session = web.config.get('_session', {})
def is_pending_domain(domain, conn=None):
if not iredutils.is_domain(domain):
return True
if not conn:
conn = web.conn_iredadmin
try:
qr = conn.select('domain_ownership',
vars={'domain': domain},
where='(domain=$domain OR alias_domain=$domain) AND verified=0',
limit=1)
if qr:
return True
else:
return False
except:
return True
def get_pending_domains(domains=None,
domain_name_only=False,
conn=None):
"""Query `iredadmin.domain_ownership` to get list of pending domains.
Return list of domain names."""
admin = session.get('username')
if domains:
domains = [str(d).lower() for d in domains if iredutils.is_domain(d)]
else:
if not session.get('is_global_admin'):
# Get managed domains
if settings.backend == 'ldap':
qr = get_managed_domains(admin=admin, conn=None)
else:
# settings.backend in ['mysql', 'pgsql']
qr = get_managed_domains(admin=admin,
domain_name_only=True,
listed_only=False)
if qr[0]:
domains = qr[1]
if not domains:
return True, []
else:
raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1]))
if not conn:
conn = web.conn_iredadmin
try:
if session.get('is_global_admin'):
qr = conn.select('domain_ownership',
where='verified=0')
else:
qr = conn.select('domain_ownership',
vars={'domains': domains, 'admin': admin},
where='admin=$admin AND (domain IN $domains OR alias_domain IN $domains) AND verified=0')
if domain_name_only:
pending_domains = set()
for r in qr:
if r.alias_domain:
pending_domains.add(r.alias_domain)
else:
pending_domains.add(r.domain)
pending_domains = [str(i).lower() for i in pending_domains if iredutils.is_domain(i)]
pending_domains.sort()
return True, pending_domains
else:
return True, list(qr)
except Exception as e:
return False, repr(e)
def get_verified_domains(domains=None, conn=None):
"""Query `iredadmin.domain_ownership` to get list of verified domains.
Return list of domain names."""
admin = session.get('username')
if domains:
domains = [str(d).lower() for d in domains if iredutils.is_domain(d)]
else:
if not session.get('is_global_admin'):
# Get managed domains
if settings.backend == 'ldap':
qr = get_managed_domains(admin=admin, conn=None)
else:
# settings.backend in ['mysql', 'pgsql']
qr = get_managed_domains(admin=admin,
domain_name_only=True,
listed_only=False)
if qr[0]:
domains = qr[1]
else:
raise web.seeother('/domains?msg=%s' % web.urlquote(qr[1]))
if not domains:
return True, []
if not conn:
conn = web.conn_iredadmin
try:
if session.get('is_global_admin'):
qr = conn.select('domain_ownership',
what='domain,alias_domain',
where='verified=1')
else:
qr = conn.select('domain_ownership',
vars={'domains': domains, 'admin': admin},
what='domain,alias_domain',
where='admin=$admin AND (domain IN $domains OR alias_domain IN $domains) AND verified=1')
verified_domains = []
for r in qr:
if r.alias_domain:
verified_domains += [str(r.alias_domain).lower()]
else:
verified_domains += [str(r.domain).lower()]
verified_domains.sort()
return True, verified_domains
except Exception as e:
return False, repr(e)
def remove_pending_domains(domains=None):
"""Remove pending domains.
:param domains: a list/tuple/set of domain names
"""
if domains:
domains = [str(d).lower() for d in domains if iredutils.is_domain(d)]
else:
return True,
conn = web.conn_iredadmin
try:
if session.get('is_global_admin'):
conn.delete('domain_ownership',
vars={'domains': domains},
where='(domain IN $domains OR alias_domain IN $domains) AND verified=0')
else:
conn.delete('domain_ownership',
vars={'domains': domains, 'admin': session.get('username')},
where='(domain IN $domains OR alias_domain IN $domains) AND admin=$admin AND verified=0')
return True,
except Exception as e:
return False, repr(e)
def _generate_verify_code():
"""Generate a random and unique string as verify code."""
s = iredutils.generate_random_strings(20)
return settings.DOMAIN_OWNERSHIP_VERIFY_CODE_PREFIX + s
def set_verify_code_for_new_domains(primary_domain, alias_domains=None, conn=None):
"""Generate new unique verify codes for mail domains.
primary_domain -- the primary mail domain name
alias_domains -- alias domains of primary domain
conn -- sql connection cursor (for `iredadmin` database)
"""
if not settings.REQUIRE_DOMAIN_OWNERSHIP_VERIFICATION:
# Bypass domain verification.
return True,
if not iredutils.is_domain(primary_domain):
return False, 'INVALID_DOMAIN_NAME'
if alias_domains:
alias_domains = [str(d).lower() for d in alias_domains if iredutils.is_domain(d)]
if not conn:
conn = web.conn_iredadmin
if session.get('is_global_admin'):
admin = ''
else:
admin = session.get('username')
try:
expire = int(time.time()) + settings.DOMAIN_OWNERSHIP_EXPIRE_DAYS * 24 * 60 * 60
if alias_domains:
for d in alias_domains:
try:
conn.insert('domain_ownership',
admin=admin,
domain=primary_domain,
alias_domain=d,
verify_code=_generate_verify_code(),
expire=expire)
except Exception as e:
if e.__class__.__name__ != 'IntegrityError':
return False, repr(e)
else:
try:
conn.insert('domain_ownership',
admin=admin,
domain=primary_domain,
verify_code=_generate_verify_code(),
expire=expire)
except Exception as e:
if e.__class__.__name__ != 'IntegrityError':
return False, repr(e)
return True,
except Exception as e:
return False, repr(e)
def mark_ownership_as_verified(rid=None, domain=None, message=None, conn=None):
"""Update `iredadmin.domain_ownership` with `verified=1` and
`message=<reason>` (optional).
@rid -- the value of column `domain_ownership.id`
@domain -- domain name of `domain_ownership.domain` or `domain_ownership.alias_domain`
@message -- the verify message
@conn -- sql connection cursor
"""
if not (rid or domain):
return True,
if domain:
if not iredutils.is_domain:
return False, 'INVALID_DOMAIN_NAME'
if not conn:
conn = web.conn_iredadmin
if not message:
message = ''
# Get value of sql column `domain_ownership.id`
if domain:
try:
qr = conn.select('domain_ownership',
vars={'domain': domain},
what='id',
where="(alias_domain=$domain) OR (domain=$domain AND alias_domain='')",
limit=1)
if qr:
rid = qr[0].id
else:
return True,
except Exception as e:
return False, repr(e)
try:
conn.update('domain_ownership',
vars={'id': rid},
verified=1,
message=message,
last_verify=web.sqlliteral('NOW()'),
where='id=$id')
return True,
except Exception as e:
return False, repr(e)
def verify_domain_ownership(domains, conn=None):
"""Verify domain ownership for given domain names.
Returned values:
(True, [(primary_domain, alias_domain), ...]): if some domains were
successfully verified.
(False, <reason>): if some error happened while verifying.
Parameters:
@domains -- a list/tuple/set of domain names
@conn -- sql connection cursor (of 'iredadmin' database)
"""
domains = [str(d).lower() for d in domains if iredutils.is_domain(d)]
if not domains:
return True, []
if not conn:
conn = web.conn_iredadmin
# Get verify code of given domains.
if session.get('is_global_admin'):
qr = conn.select(
'domain_ownership',
vars={'domains': domains},
where="verified=0 AND ((domain IN $domains AND alias_domain='') OR (alias_domain IN $domains))",
)
else:
qr = conn.select(
'domain_ownership',
vars={'domains': domains, 'admin': session.get('username')},
where="verified=0 AND admin=$admin AND ((domain IN $domains AND alias_domain='') OR (alias_domain IN $domains))",
)
if not qr:
return True, []
verified_domains = []
expire = int(time.time()) + settings.DOMAIN_OWNERSHIP_EXPIRE_DAYS * 24 * 60 * 60
for r in qr:
rid = int(r.id)
domain = str(r.domain).lower()
alias_domain = str(r.alias_domain).lower()
verify_code = str(r.verify_code)
if iredutils.is_domain(alias_domain):
verify_domain = alias_domain
else:
verify_domain = domain
# web files
_web_file = str(verify_domain + '/' + verify_code)
_verified = False
_verified_reason = ''
_verify_result = ''
# Verify web files
for _scheme in ['http', 'https']:
url = _scheme + '://' + _web_file
# settings.HTTP_PROXY
_proxies = {}
if settings.HTTP_PROXY:
_proxies = {
'http': settings.HTTP_PROXY,
'https': settings.HTTP_PROXY,
}
# MAXFILESIZE, 1024) # maximum file size allowed to download, read, fetch
# setopt(c.BUFFERSIZE, 1024) # buffer read size: 1024 bytes
# _resp_code == 200:
try:
with requests.get(url,
proxies=_proxies,
verify=False, # no SSL certificate verifying
timeout=settings.DOMAIN_OWNERSHIP_VERIFY_TIMEOUT,
stream=True, # defer downloading the response body
) as resp:
if resp.status_code == 200:
pass
elif resp.status_code == 404:
_verify_result += '%s:// file not found. ' % _scheme
else:
_verify_result += '%s://, response code must be 200, but got %d. ' % (_scheme, resp.status_code)
continue
try:
if int(r.headers['content-length']) < 1024:
_body = r.content.strip()
if _body == verify_code:
_verified = True
_verified_reason = '%s matches' % _scheme
break
else:
_verify_result += '{}:// file content too long. '.format(_scheme)
continue
except Exception as e:
_verify_result += '{}:// error while reading file content: {}. '.format(_scheme, repr(e))
continue
except Exception as e:
_verify_result += 'Error while verifying {}://: {}. '.format(_scheme, repr(e))
# Verify TXT type DNS record
if not _verified:
try:
_res = resolver.Resolver()
_res.timeout = settings.DOMAIN_OWNERSHIP_VERIFY_TIMEOUT
qr_dns = _res.query(domain, 'TXT')
for i in qr_dns:
_txt = i.to_text().strip('"')
if verify_code == _txt:
_verified = True
_verified_reason = 'DNS record matches'
break
_verify_result += "Verify code is not found as one of TXT type DNS records."
except Exception as e:
_verify_result += 'Error while querying DNS: %s.' % repr(e)
if _verified:
verified_domains += [(domain, alias_domain)]
qr = mark_ownership_as_verified(rid=rid, message=_verified_reason, conn=conn)
if not qr[0]:
return qr
else:
# Update last verify time, verify result, and expire time
try:
conn.update('domain_ownership',
message=_verify_result,
last_verify=web.sqlliteral('NOW()'),
expire=expire,
where='id=%d' % rid)
except Exception as e:
return False, repr(e)
return True, verified_domains

126
libs/panel/log.py Normal file
View File

@@ -0,0 +1,126 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
import settings
from libs import iredutils
from libs.panel import LOG_EVENTS
if settings.backend == 'ldap':
from libs.ldaplib.general import is_domain_admin
from libs.ldaplib.admin import get_managed_domains
else:
from libs.sqllib.general import is_domain_admin
from libs.sqllib.admin import get_managed_domains
session = web.config.get('_session')
def list_logs(event='all', domain='all', admin='all', cur_page=1):
event = web.safestr(event)
domain = web.safestr(domain)
admin = web.safestr(admin)
cur_page = int(cur_page)
sql_vars = {}
sql_wheres = []
sql_where = ''
if event not in LOG_EVENTS:
event = "all"
if event != 'all':
sql_vars['event'] = event
sql_wheres += ["event=$event"]
if iredutils.is_domain(domain):
if session.get('is_global_admin') or is_domain_admin(domain=domain, admin=session['username'], conn=None):
sql_vars['domain'] = domain
sql_wheres += ["domain=$domain"]
else:
# Get managed domains.
if not session.get("is_global_admin"):
if settings.backend == 'ldap':
qr = get_managed_domains(admin=session["username"],
attributes=None,
domain_name_only=True,
conn=None)
else:
qr = get_managed_domains(admin=session["username"],
domain_name_only=True,
listed_only=True,
conn=None)
if qr[0]:
sql_vars["managed_domains"] = qr[1]
sql_wheres += ["domain IN $managed_domains"]
else:
return qr
if iredutils.is_email(admin):
if session.get('is_global_admin'):
sql_vars['admin'] = admin
sql_wheres += ["admin=$admin"]
else:
sql_vars['admin'] = session.get('username')
sql_wheres += ["admin=$admin"]
else:
if not session.get('is_global_admin'):
sql_vars['admin'] = session.get('username')
sql_wheres += ["admin=$admin"]
# Get number of total records.
if sql_wheres:
sql_where = ' AND '.join(sql_wheres)
qr = web.conn_iredadmin.select(
'log',
vars=sql_vars,
what='COUNT(id) AS total',
where=sql_where,
)
else:
qr = web.conn_iredadmin.select('log', what='COUNT(id) AS total')
total = qr[0].total or 0
# Get records.
if sql_wheres:
qr = web.conn_iredadmin.select(
'log',
vars=sql_vars,
where=sql_where,
offset=(cur_page - 1) * settings.PAGE_SIZE_LIMIT,
limit=settings.PAGE_SIZE_LIMIT,
order='timestamp DESC',
)
else:
# No addition filter.
qr = web.conn_iredadmin.select(
'log',
offset=(cur_page - 1) * settings.PAGE_SIZE_LIMIT,
limit=settings.PAGE_SIZE_LIMIT,
order='timestamp DESC',
)
return total, list(qr)
def delete_logs(form, delete_all=False):
if delete_all:
try:
web.conn_iredadmin.delete('log', where="1=1")
return True,
except Exception as e:
return False, repr(e)
else:
ids = form.get('id', [])
if ids:
try:
web.conn_iredadmin.delete('log', where="id IN %s" % web.db.sqlquote(ids))
return True,
except Exception as e:
return False, repr(e)
return True,

59
libs/regxes.py Normal file
View File

@@ -0,0 +1,59 @@
# Regular expressions of email address, IP address, network.
import re
# Email address.
#
# - `+`, `=` are used in SRS rewritten addresses.
# - `/` is sub-folder. e.g. 'john+lists/abc/def@domain.com' will create
# directory `lists` and its sub-folders `lists/abc/`, `lists/abc/def`.
email = r"""[\w\-\#][\w\-\.\+\=\/\&\#]*@[\w\-][\w\-\.]*\.[a-zA-Z0-9\-]{2,25}"""
cmp_email = re.compile(r"^" + email + r"$", re.IGNORECASE | re.DOTALL | re.ASCII)
# Email address allowed by locally created mail user.
#
# `auth_email` allows less characters than `email`.
# Disallowed chars: `+`, `=`, `/`.
auth_email = r"""[\w\-\#][\w\-\=\.\&\#]*@[\w\-][\w\-\.]*\.[a-zA-Z0-9\-]{2,25}"""
cmp_auth_email = re.compile(r"^" + auth_email + r"$", re.IGNORECASE | re.DOTALL | re.ASCII)
# Wildcard sender address: 'user@*'
wildcard_addr = r"""[\w\-][\w\-\.\+\=]*@\*"""
cmp_wildcard_addr = re.compile(r"^" + wildcard_addr + r"$", re.IGNORECASE | re.DOTALL | re.ASCII)
#
# Domain name
#
# Single domain name.
domain = r"""[\w\-][\w\-\.]*\.[a-z0-9\-]{2,25}"""
cmp_domain = re.compile(r"^" + domain + r"$", re.IGNORECASE | re.DOTALL | re.ASCII)
# Top level domain. e.g. .com, .biz, .org.
top_level_domain = r"""[a-z0-9\-]{2,25}"""
cmp_top_level_domain = re.compile(r"^" + top_level_domain + r"$", re.IGNORECASE | re.DOTALL | re.ASCII)
# Valid first char of domain name, email address.
valid_account_first_char = r"""^[0-9a-zA-Z]{1,1}$"""
cmp_valid_account_first_char = re.compile(r"^" + valid_account_first_char + r"$", re.IGNORECASE)
# WARNING: This is used for simple URL matching, not used to verify IP address.
ip = r"[0-9a-zA-Z\.\:]+"
# Wildcard IPv4: 192.168.0.*
wildcard_ipv4 = r"(?:[\d\*]{1,3})\.(?:[\d\*]{1,3})\.(?:[\d\*]{1,3})\.(?:[\d\*]{1,3})$"
cmp_wildcard_ipv4 = re.compile(wildcard_ipv4, re.IGNORECASE | re.DOTALL)
# Mailing list id, a server-wide unique 36-char string.
mailing_list_id = r"[a-zA-Z0-9\-]{36}"
cmp_mailing_list_id = re.compile(r"^" + mailing_list_id + r"$")
# Mailing list subscription confirm token. a 32-char string.
mailing_list_confirm_token = r"[a-zA-Z0-9]{32}"
cmp_mailing_list_confirm_token = re.compile(r"^" + mailing_list_confirm_token + r"$")
#
# Mailbox
#
# Set mailbox folder name. Could be either empty or up to 20 characters.
mailbox_folder = r"""[a-zA-Z0-9]{0,20}"""
cmp_mailbox_folder = re.compile(r"^" + mailbox_folder + r"$")

66
libs/sqllib/__init__.py Normal file
View File

@@ -0,0 +1,66 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
import settings
from libs.logger import logger
class MYSQLWrap:
def __del__(self):
try:
self.conn.ctx.db.close()
except Exception:
pass
def connect(self):
conn = web.database(
dbn='mysql',
host=settings.vmail_db_host,
port=int(settings.vmail_db_port),
db=settings.vmail_db_name,
user=settings.vmail_db_user,
pw=settings.vmail_db_password,
charset='utf8')
conn.supports_multiple_insert = True
return conn
def __init__(self):
try:
self.conn = self.connect()
except AttributeError:
# Reconnect if error raised: MySQL server has gone away.
self.conn = self.connect()
except Exception as e:
logger.error(e)
class PGSQLWrap:
def __del__(self):
try:
self.conn.ctx.db.close()
except Exception:
pass
def __init__(self):
# Initial DB connection and cursor.
try:
self.conn = web.database(
dbn='postgres',
host=settings.vmail_db_host,
port=int(settings.vmail_db_port),
db=settings.vmail_db_name,
user=settings.vmail_db_user,
pw=settings.vmail_db_password,
)
self.conn.supports_multiple_insert = True
except Exception as e:
logger.error(e)
if settings.backend == 'mysql':
SQLWrap = MYSQLWrap
elif settings.backend == 'pgsql':
SQLWrap = PGSQLWrap

1279
libs/sqllib/admin.py Normal file

File diff suppressed because it is too large Load Diff

656
libs/sqllib/alias.py Normal file
View File

@@ -0,0 +1,656 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
import settings
from libs import iredutils, form_utils
from libs.logger import logger, log_activity
from libs.sqllib import SQLWrap, decorators
from libs.sqllib import general as sql_lib_general
from libs.sqllib import domain as sql_lib_domain
session = web.config.get('_session')
@decorators.require_domain_access
def change_email(mail, new_mail, conn=None):
if not iredutils.is_email(mail):
return False, 'INVALID_OLD_EMAIL'
if not iredutils.is_email(new_mail):
return False, 'INVALID_NEW_EMAIL'
old_domain = mail.split('@', 1)[-1]
new_domain = new_mail.split('@', 1)[-1]
if old_domain != new_domain:
return False, 'PERMISSION_DENIED'
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
if not sql_lib_general.is_email_exists(mail=mail, conn=conn):
return False, 'OLD_EMAIL_NOT_EXIST'
if sql_lib_general.is_email_exists(mail=new_mail, conn=conn):
return False, 'NEW_EMAIL_ALREADY_EXISTS'
# Change email address
try:
sql_vars = {'mail': mail, 'new_mail': new_mail}
conn.update('alias',
vars=sql_vars,
address=new_mail,
where='address=$mail')
# Update per-user mail forwardings, alias memberships
conn.update('forwardings',
vars=sql_vars,
address=new_mail,
where='address=$mail')
conn.update('forwardings',
vars=sql_vars,
forwarding=new_mail,
where='forwarding=$mail')
# Update moderators
conn.update('moderators',
vars=sql_vars,
address=new_mail,
where='address=$mail')
conn.update('moderators',
vars=sql_vars,
moderator=new_mail,
where='moderator=$mail')
log_activity(event='update',
domain=old_domain,
msg="Change alias account email address: {} -> {}.".format(mail, new_mail))
return True,
except Exception as e:
return False, repr(e)
def add_alias_from_form(domain, form, conn=None):
# Get domain name, username, cn.
form_domain = form_utils.get_domain_name(form)
username = web.safestr(form.get('listname')).strip().lower()
mail = username + '@' + form_domain
if domain != form_domain:
return False, 'PERMISSION_DENIED'
if not iredutils.is_domain(domain):
return False, 'INVALID_DOMAIN_NAME'
if not iredutils.is_auth_email(mail):
return False, 'INVALID_MAIL'
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
# Check account existing.
if sql_lib_general.is_email_exists(mail=mail, conn=conn):
return False, 'ALREADY_EXISTS'
# Get domain profile.
qr_profile = sql_lib_domain.profile(conn=conn, domain=domain)
if qr_profile[0]:
domain_profile = qr_profile[1]
else:
return qr_profile
# Check account limit.
num_exist = num_aliases_under_domain(conn=conn, domain=domain)
if domain_profile.aliases == -1:
return False, 'NOT_ALLOWED'
elif domain_profile.aliases > 0:
if domain_profile.aliases <= num_exist:
return False, 'EXCEEDED_DOMAIN_ACCOUNT_LIMIT'
# Define columns and values used to insert.
columns = {
'address': mail, 'domain': domain,
'name': form_utils.get_name(form=form),
'created': iredutils.get_gmttime(), 'active': 1,
'accesspolicy': form_utils.get_list_access_policy(form=form,
input_name='accessPolicy',
default_value='public'),
}
# Get access policy
try:
conn.insert('alias', **columns)
log_activity(msg="Create mail alias: %s." % mail,
domain=domain,
event='create')
return True,
except Exception as e:
return False, repr(e)
def delete_aliases(accounts, conn=None):
"""Delete alias accounts under same domain."""
accounts = [str(i).lower() for i in accounts if iredutils.is_email(i)]
if not accounts:
return True,
# Get domain from first account
domain = accounts[0].split('@', 1)[-1]
if not iredutils.is_domain(domain):
return True,
sql_vars = {'domain': domain, 'accounts': accounts}
try:
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
conn.delete('alias',
vars=sql_vars,
where='address IN $accounts')
conn.delete('forwardings',
vars=sql_vars,
where='address IN $accounts OR forwarding IN $accounts')
log_activity(event='delete',
domain=accounts[0].split('@', 1)[-1],
msg="Delete alias: %s." % ', '.join(accounts))
except Exception as e:
return False, repr(e)
# Remove alias from domain.settings: default_groups
qr = sql_lib_domain.remove_default_maillists_in_domain_setting(domain=domain,
maillists=accounts,
conn=conn)
if not qr[0]:
return qr
return True,
@decorators.require_domain_access
def num_aliases_under_domain(conn, domain, disabled_only=False, first_char=None):
if not iredutils.is_domain(domain):
return False, 'INVALID_DOMAIN_NAME'
num = 0
sql_vars = {'domain': domain}
sql_where = ''
if disabled_only:
sql_where = ' AND active=0'
if first_char:
sql_where += ' AND address LIKE %s' % web.sqlquote(first_char.lower() + '%')
try:
qr = conn.select('alias',
vars=sql_vars,
what='COUNT(address) AS total',
where='domain=$domain %s' % sql_where)
num = qr[0].total or 0
except:
pass
return num
@decorators.require_domain_access
def get_basic_alias_profiles(domain,
columns=None,
first_char=None,
page=0,
email_only=False,
disabled_only=False,
conn=None):
"""Get all aliases under domain.
Return data:
(True, [{'mail': 'alias@domain.com',
'name': '...',
...other profiles in `vmail.alias` table...
'members', [...],
'moderators', [...]]
"""
domain = web.safestr(domain).lower()
if not iredutils.is_domain(domain):
raise web.seeother('/domains?msg=INVALID_DOMAIN_NAME')
sql_vars = {'domain': domain}
if columns:
sql_what = ','.join(columns)
else:
if email_only:
sql_what = 'address'
else:
sql_what = '*'
# Get alias members
additional_sql_where = ''
if first_char:
additional_sql_where = ' AND address LIKE %s' % web.sqlquote(first_char.lower() + '%')
if disabled_only:
additional_sql_where = ' AND active=0'
# Get basic alias profiles first
try:
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
if page:
qr = conn.select('alias',
vars=sql_vars,
what=sql_what,
where='domain=$domain %s' % additional_sql_where,
order='address ASC',
limit=settings.PAGE_SIZE_LIMIT,
offset=(page - 1) * settings.PAGE_SIZE_LIMIT)
else:
qr = conn.select('alias',
vars=sql_vars,
what=sql_what,
where='domain=$domain %s' % additional_sql_where,
order='address ASC')
if email_only:
emails = []
for r in qr:
email = str(r.address).lower()
emails.append(email)
emails.sort()
return True, emails
else:
return True, list(qr)
except Exception as e:
return False, repr(e)
@decorators.require_domain_access
def get_profile(mail,
with_members=True,
with_moderators=True,
conn=None):
if not iredutils.is_email(mail):
return False, 'INVALID_MAIL'
try:
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
qr = conn.select('alias',
vars={'address': mail},
where='address=$address',
limit=1)
if qr:
profile = list(qr)[0]
if with_members:
_qr = get_member_emails(mail=mail, conn=conn)
if _qr[0]:
profile['members'] = _qr[1]
profile['members'].sort()
else:
return _qr
if with_moderators:
_qr = get_moderators(mail=mail, conn=conn)
if _qr[0]:
profile['moderators'] = _qr[1]
profile['moderators'].sort()
else:
return _qr
return True, profile
else:
return False, 'NO_SUCH_ACCOUNT'
except Exception as e:
return False, repr(e)
@decorators.require_domain_access
def update(mail, profile_type, form, conn=None):
mail = web.safestr(mail).lower()
domain = mail.split('@', 1)[-1]
if not iredutils.is_email(mail):
return False, 'INVALID_MAIL'
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
# change email address
if profile_type == 'rename':
# new email address
new_mail = web.safestr(form.get('new_mail_username')).strip().lower() + '@' + domain
qr = change_email(mail=mail, new_mail=new_mail, conn=conn)
if qr[0]:
raise web.seeother('/profile/alias/general/%s?msg=EMAIL_CHANGED' % new_mail)
else:
raise web.seeother('/profile/alias/general/{}?msg={}'.format(new_mail, web.urlquote(qr[1])))
# Pre-defined.
values = {'modified': iredutils.get_gmttime()}
# Get cn.
cn = form.get('cn', '')
values['name'] = cn
# check account status.
values['active'] = 0
if 'accountStatus' in form:
# Enabled.
values['active'] = 1
# Get access policy.
access_policy = str(form.get('accessPolicy'))
if access_policy in iredutils.MAILLIST_ACCESS_POLICIES:
values['accesspolicy'] = access_policy
# Get members & moderators from web form.
_members = form_utils.get_multi_values_from_textarea(form=form,
input_name='members',
is_email=True)
_members = list({iredutils.lower_email_with_upper_ext_address(v) for v in _members})
_moderators = [str(v).strip().lower() for v in form.get('moderators', '').splitlines()]
_moderators = list({iredutils.lower_email_with_upper_ext_address(v)
for v in _moderators
if iredutils.is_email(v) or v.startswith('*@')})
_moderators_wildcard = [v for v in _moderators if iredutils.is_domain(v.split('@', 1)[-1])]
# Remove non-exist accounts in same domain.
# Get members & moderators which in same domain.
_members_in_domain = [i for i in _members if i.endswith('@' + domain)]
_members_not_in_domain = [i for i in _members if not i.endswith('@' + domain)]
_moderators_in_domain = [i for i in _moderators if i.endswith('@' + domain) and i not in _moderators_wildcard]
_moderators_not_in_domain = [i for i in _moderators if not (i.endswith('@' + domain) or i in _moderators_wildcard)]
# Verify internal users
addresses_in_domain = []
_addresses_in_domain = list(set(_members_in_domain + _moderators_in_domain))
if _addresses_in_domain:
try:
# Remove non-existing addresses
_qr = sql_lib_general.filter_existing_emails(mails=_addresses_in_domain, conn=conn)
addresses_in_domain = _qr['exist']
except Exception as e:
logger.error(e)
members_in_domain = [v for v in _members_in_domain if v in addresses_in_domain]
moderators_in_domain = [v for v in _moderators_in_domain if v in addresses_in_domain]
try:
# Update profile
conn.update('alias',
vars={'address': mail},
where='address=$address',
**values)
# Delete all members and moderators first
conn.delete('forwardings',
vars={'address': mail},
where='address=$address')
conn.delete('moderators',
vars={'address': mail},
where='address=$address')
# Add members by inserting new records
_all_members = members_in_domain + _members_not_in_domain
if _all_members:
v = []
for _member in _all_members:
v += [{'address': mail,
'forwarding': _member,
'domain': domain,
'dest_domain': _member.split('@', 1)[-1],
'active': values['active'],
'is_list': 1}]
conn.multiple_insert('forwardings', values=v)
# Add moderators by inserting new records
_all_moderators = moderators_in_domain + _moderators_not_in_domain + _moderators_wildcard
if _all_moderators:
v = []
for _moderator in _all_moderators:
v += [{'address': mail,
'moderator': _moderator,
'domain': domain,
'dest_domain': _moderator.split('@', 1)[-1]}]
conn.multiple_insert('moderators', values=v)
# Log changes.
msg = "Update alias profile (%s)." % mail
if access_policy:
msg += " Access policy: %s." % access_policy
if _all_members:
msg += " Members: %s." % (', '.join(_all_members))
else:
msg += " No members."
if _all_moderators:
msg += " Moderators: %s." % (', '.join(_all_moderators))
else:
msg += " No moderators."
log_activity(msg=msg, username=mail, domain=domain, event='update')
return True,
except Exception as e:
return False, repr(e)
def get_member_emails(mail, conn=None):
"""Get members of mail alias account. Return a list of mail addresses.
Return a list with all members' email addresses."""
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
try:
qr = conn.select(
'forwardings',
vars={'mail': mail},
what='forwarding',
where='address=$mail AND is_list=1',
)
_addresses = [iredutils.lower_email_with_upper_ext_address(i.forwarding)
for i in qr if iredutils.is_email(i.forwarding)]
_addresses.sort()
return True, _addresses
except Exception as e:
return False, repr(e)
def get_moderators(mail, conn=None):
"""Get moderators of given mail alias account.
Return a list with all moderators' email addresses."""
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
try:
qr = conn.select('moderators',
vars={'mail': mail},
what='moderator',
where='address=$mail')
_addresses = [iredutils.lower_email_with_upper_ext_address(i.moderator)
for i in qr
if iredutils.is_email(i.moderator) or i.moderator.startswith('*@')]
_addresses.sort()
return True, _addresses
except Exception as e:
return False, repr(e)
def reset_members(mail, members, conn=None):
"""Assign all given addresses specified in `@members` as members."""
_addresses = {iredutils.lower_email_with_upper_ext_address(i)
for i in members
if iredutils.is_email(i)}
domain = mail.split('@', 1)[-1]
_addresses_in_domain = [v for v in _addresses if v.endswith('@' + domain) and v != mail]
_addresses_not_in_domain = [v for v in _addresses if not v.endswith('@' + domain)]
del _addresses
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
# Verify existence of addresses in same domain
if _addresses_in_domain:
try:
# Remove non-existing addresses
qr = sql_lib_general.filter_existing_emails(mails=_addresses_in_domain, conn=conn)
_addresses_in_domain = qr['exist']
except Exception as e:
logger.error(e)
try:
# Delete all existing members first
conn.delete('forwardings',
vars={'mail': mail},
where='address=$mail AND is_list=1')
# Add member by inserting new record
_all_addresses = _addresses_in_domain + _addresses_not_in_domain
if _all_addresses:
v = []
for i in _all_addresses:
v += [{'address': mail,
'forwarding': i,
'domain': domain,
'dest_domain': i.split('@', 1)[-1],
'is_list': 1}]
conn.multiple_insert('forwardings', values=v)
log_activity(msg='Reset alias ({}) members to: {}'.format(mail, ', '.join(_all_addresses)),
admin=session.get('username'),
username=mail,
domain=domain,
event='update')
return True,
except Exception as e:
return False, repr(e)
def update_members(mail,
new_members=None,
removed_members=None,
conn=None):
"""Add new members to mail alias account, and remove removed_members."""
_new = []
if new_members:
_new = [iredutils.lower_email_with_upper_ext_address(i)
for i in new_members if iredutils.is_email(i)]
_removed = []
if removed_members:
_removed = [iredutils.lower_email_with_upper_ext_address(i)
for i in removed_members if iredutils.is_email(i)]
if not (_new or _removed):
return True, 'NO_VALID_MEMBERS'
domain = mail.split('@', 1)[-1]
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
# Verify existence of addresses in same domain
_new_in_domain = set()
_new_not_in_domain = set()
if _new:
for i in _new:
if i.endswith('@' + domain):
_new_in_domain.add(i)
else:
_new_not_in_domain.add(i)
# remove self
_new_in_domain.discard(mail)
if _new_in_domain:
try:
# Remove non-existing addresses
qr = sql_lib_general.filter_existing_emails(mails=_new_in_domain, conn=conn)
_new_in_domain = qr['exist']
except Exception as e:
logger.error(e)
# Get existing members
qr = get_member_emails(mail=mail, conn=conn)
if qr[0]:
_old_members = qr[1]
else:
return qr
# Add new, remove removed
_members = set(_old_members)
_members.update(_new_in_domain)
_members.update(_new_not_in_domain)
_members -= set(_removed)
try:
# Delete all existing members first
conn.delete('forwardings',
vars={'mail': mail},
where='address=$mail AND is_list=1')
# Add member by inserting new record
if _members:
v = []
for i in _members:
v += [{'address': mail,
'forwarding': i,
'domain': domain,
'dest_domain': i.split('@', 1)[-1],
'is_list': 1}]
conn.multiple_insert('forwardings', values=v)
log_activity(msg='Update alias ({}) members to: {}'.format(mail, ', '.join(_members)),
admin=session.get('username'),
username=mail,
domain=domain,
event='update')
return True,
except Exception as e:
return False, repr(e)

73
libs/sqllib/api_utils.py Normal file
View File

@@ -0,0 +1,73 @@
import datetime
from libs import form_utils
from libs.sqllib import general as sql_lib_general
from libs.sqllib import sqlutils
import settings
def get_form_password_dict(form,
domain,
input_name='password',
min_passwd_length=None,
max_passwd_length=None):
"""Extract password from form, verify it, return both plain and hashed password.
>>> get_form_password_dict(form=form,
domain='domain.tld',
input_name='password',
min_passwd_length=None,
max_passwd_length=None)
(True, {'pw_plain', '123456',
'pw_hash', '{SSHA512}....'})
"""
if input_name not in form:
return False, 'NO_PASSWORD'
# Get min/max password length from domain profile
if not (min_passwd_length or max_passwd_length):
qr = sql_lib_general.get_domain_settings(domain=domain)
if qr[0]:
ds = qr[1]
min_passwd_length = ds.get('min_passwd_length', settings.min_passwd_length)
max_passwd_length = ds.get('max_passwd_length', settings.max_passwd_length)
qr = form_utils.get_password(form=form,
input_name=input_name,
confirm_pw_input_name=input_name,
min_passwd_length=min_passwd_length,
max_passwd_length=max_passwd_length)
return qr
def export_sql_record(record, remove_columns=None):
"""Convert some values in SQL format to general string.
- datetime
- settings
"""
for (k, v) in list(record.items()):
if remove_columns:
if k in remove_columns:
record.pop(k)
continue
if isinstance(v, datetime.datetime):
record[k] = v.isoformat()
elif k == 'settings':
record[k] = sqlutils.account_settings_string_to_dict(v)
return record
def export_sql_records(records, remove_columns=None):
new_records = []
for rcd in records:
new_rcd = export_sql_record(record=rcd, remove_columns=remove_columns)
new_records.append(new_rcd)
return new_records

138
libs/sqllib/auth.py Normal file
View File

@@ -0,0 +1,138 @@
import web
import settings
from libs import iredutils, iredpwd
from libs.l10n import TIMEZONES
from libs.sqllib import sqlutils
session = web.config.get('_session', {})
def auth(conn,
username,
password,
account_type='admin',
verify_password=False):
if not iredutils.is_email(username):
return False, 'INVALID_USERNAME'
if not password:
return False, 'EMPTY_PASSWORD'
username = str(username).lower()
password = str(password)
domain = username.split('@', 1)[-1]
# Query account from SQL database.
if account_type == 'admin':
# separate admin accounts
result = conn.select('admin',
vars={'username': username},
where="username=$username AND active=1",
what='password, language, settings',
limit=1)
# mail user marked as domain admin
if not result:
result = conn.select(
["mailbox", "domain"],
vars={'username': username},
where="mailbox.username=$username AND mailbox.active=1 AND (mailbox.isadmin=1 OR mailbox.isglobaladmin=1) AND mailbox.domain=domain.domain and domain.active=1",
what='mailbox.password, mailbox.language, mailbox.isadmin, mailbox.isglobaladmin, mailbox.settings',
limit=1,
)
if result:
session['admin_is_mail_user'] = True
elif account_type == 'user':
result = conn.select('mailbox',
vars={'username': username},
what='password, language, isadmin, isglobaladmin, settings',
where="username=$username AND active=1",
limit=1)
else:
return False, 'INVALID_ACCOUNT_TYPE'
if not result:
# Account not found.
# Do NOT return msg like 'Account does not ***EXIST***', crackers
# can use it to verify valid accounts.
return False, 'INVALID_CREDENTIALS'
record = result[0]
password_sql = str(record.password)
account_settings = sqlutils.account_settings_string_to_dict(str(record.settings))
# Verify password
if not iredpwd.verify_password_hash(password_sql, password):
return False, 'INVALID_CREDENTIALS'
if not verify_password:
session['username'] = username
if account_type == 'user':
session['account_is_mail_user'] = True
# Set preferred language.
session['lang'] = web.safestr(record.get('language', settings.default_language))
# Set timezone (GMT-XX:XX).
# Priority: per-user timezone > per-domain > global setting
timezone = settings.LOCAL_TIMEZONE
if 'timezone' in account_settings:
tz_name = account_settings['timezone']
if tz_name in TIMEZONES:
timezone = TIMEZONES[tz_name]
else:
# Get per-domain timezone
qr_domain = conn.select('domain',
vars={'domain': domain},
what='settings',
where='domain=$domain',
limit=1)
if qr_domain:
domain_settings = sqlutils.account_settings_string_to_dict(str(qr_domain[0]['settings']))
if 'timezone' in domain_settings:
tz_name = domain_settings['timezone']
if tz_name in TIMEZONES:
timezone = TIMEZONES[tz_name]
session['timezone'] = timezone
# Set session['is_global_admin']
if session.get('admin_is_mail_user'):
if record.get('isglobaladmin', 0) == 1:
session['is_global_admin'] = True
else:
session['is_normal_admin'] = True
# Set session['allowed_to_grant_admin']
if 'grant_admin' in account_settings:
session['allowed_to_grant_admin'] = True
else:
try:
result = conn.select('domain_admins',
vars={'username': username, 'domain': 'ALL'},
what='domain',
where='username=$username AND domain=$domain',
limit=1)
if result:
session['is_global_admin'] = True
else:
if account_type == 'admin':
session['is_normal_admin'] = True
except:
pass
if session['is_global_admin']:
if not iredutils.is_allowed_global_admin_login_ip(client_ip=web.ctx.ip):
session.kill()
raise web.seeother('/login?msg=NOT_ALLOWED_IP')
session['logged'] = True
web.config.session_parameters['cookie_name'] = 'iRedAdmin-Pro'
web.config.session_parameters['ignore_change_ip'] = settings.SESSION_IGNORE_CHANGE_IP
web.config.session_parameters['ignore_expiry'] = False
return True, {'account_settings': account_settings}

201
libs/sqllib/decorators.py Normal file
View File

@@ -0,0 +1,201 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
import settings
from controllers import decorators as base_decorators
from controllers.utils import api_render
from libs import iredutils
from libs.logger import logger
from libs.sqllib import general as sql_lib_general
session = web.config.get('_session', {})
# require_api_auth_token = base_decorators.require_api_auth_token
require_login = base_decorators.require_login
require_admin_login = base_decorators.require_admin_login
require_global_admin = base_decorators.require_global_admin
csrf_protected = base_decorators.csrf_protected
require_permission_create_domain = base_decorators.require_permission_create_domain
require_preference_access = base_decorators.require_preference_access
api_require_admin_login = base_decorators.api_require_admin_login
api_require_global_admin = base_decorators.api_require_global_admin
def require_domain_access(func):
def proxyfunc(*args, **kw):
if not session.get('username'):
raise web.seeother('/login?msg=LOGIN_REQUIRED')
# Check domain global admin.
if session.get('is_global_admin'):
return func(*args, **kw)
else:
username = session.get('username')
# admin/user is viewing its own data
if username == kw.get('mail') \
or username.endswith('@' + kw.get('domain', 'NONE')):
return func(*args, **kw)
if 'domain' in kw and iredutils.is_domain(kw.get('domain')):
domain = web.safestr(kw['domain'])
elif 'mail' in kw and iredutils.is_email(kw.get('mail')):
domain = web.safestr(kw['mail']).split('@')[-1]
elif 'admin' in kw and iredutils.is_email(kw.get('admin')):
domain = web.safestr(kw['admin']).split('@')[-1]
else:
domain = None
# Try to use the first valid domain name or email address as
# key, it's passed from controllers/*.
for arg in args:
if iredutils.is_domain(arg):
domain = arg
break
elif iredutils.is_email(arg):
domain = arg.split('@', 1)[-1]
break
if not domain:
if settings.LOG_PERMISSION_DENIED:
logger.error("PERMISSION_DENIED (1) raised in "
"@require_domain_access, triggered in module: "
"%s.py, function: %s(). No target domain for "
"accessing." % (func.__module__, func.__name__))
raise web.seeother('/domains?msg=PERMISSION_DENIED')
# Check whether is domain admin.
is_admin = sql_lib_general.is_domain_admin(domain=domain,
admin=username)
if is_admin:
return func(*args, **kw)
else:
if settings.LOG_PERMISSION_DENIED:
logger.error("PERMISSION_DENIED (2) raised in "
"@require_domain_access, triggered in module: %s.py, "
"function: %s(), accessing data: admin=%s, "
"domain=%s" % (func.__module__, func.__name__, username, domain))
raise web.seeother('/domains?msg=PERMISSION_DENIED')
return proxyfunc
def require_user_login(func):
def proxyfunc(self, *args, **kw):
if session.get('account_is_mail_user'):
return func(self, *args, **kw)
"""
elif session.get('is_normal_admin') and session.get('admin_is_mail_user'):
# Admin manages other domains but not self domain.
# <admin>@<domain.com> doesn't manage <domain.com>
admin = session.get('username')
domain = admin.split('@', 1)[-1]
if not sql_lib_general.is_domain_admin(domain=domain, admin=admin):
return func(self, *args, **kw)
"""
session.kill()
raise web.seeother('/login?msg=LOGIN_REQUIRED')
return proxyfunc
# self-service.
def require_ml_owner_or_moderator(func):
def proxyfunc(*args, **kw):
username = session.get('username')
if not username:
raise web.seeother('/login?msg=LOGIN_REQUIRED')
mail = None
if 'mail' in kw:
# the mailing list
mail = kw['mail']
if not iredutils.is_email(mail):
raise web.seeother("/self-service/mls?msg=INVALID_MAILLIST")
else:
for i in args:
if iredutils.is_email(i):
mail = i
break
if not mail:
raise web.seeother("/self-service/mls?msg=INVALID_MAILLIST")
# Check whether user is an owner or moderator.
_is_owner_or_moderator = sql_lib_general.is_ml_owner_or_moderator(ml=mail, user=username, conn=None)
if _is_owner_or_moderator:
return func(*args, **kw)
else:
if settings.LOG_PERMISSION_DENIED:
logger.error("PERMISSION_DENIED (2) raised in "
"@require_ml_owner_or_moderator, triggered in module: %s.py, "
"function: %s(), accessing data: user=%s, "
"maillist=%s" % (func.__module__, func.__name__, username, mail))
raise web.seeother('/self-service/mls?msg=PERMISSION_DENIED')
return proxyfunc
def api_require_domain_access(func):
def proxyfunc(*args, **kw):
if not iredutils.is_allowed_api_client(web.ctx.ip):
return api_render((False, 'NOT_AUTHORIZED'))
if not session.get('username'):
return api_render((False, 'LOGIN_REQUIRED'))
# Check domain global admin.
if session.get('is_global_admin'):
return func(*args, **kw)
else:
username = session.get('username')
# admin/user is viewing its own data
if username == kw.get('mail') \
or username.endswith('@' + kw.get('domain', 'NONE')):
return func(*args, **kw)
if 'domain' in kw and iredutils.is_domain(kw.get('domain')):
domain = web.safestr(kw['domain'])
elif 'mail' in kw and iredutils.is_email(kw.get('mail')):
domain = web.safestr(kw['mail']).split('@')[-1]
elif 'admin' in kw and iredutils.is_email(kw.get('admin')):
domain = web.safestr(kw['admin']).split('@')[-1]
else:
domain = None
# Try to use the first valid domain name or email address as
# key, it's passed from controllers/*.
for arg in args:
if iredutils.is_domain(arg):
domain = arg
break
elif iredutils.is_email(arg):
domain = arg.split('@', 1)[-1]
break
if not domain:
if settings.LOG_PERMISSION_DENIED:
logger.error("PERMISSION_DENIED (1) raised in "
"@require_domain_access: module=%s.py, "
"function=%s(), admin=%s. "
"No target domain for accessing." % (func.__module__, func.__name__, username))
return api_render((False, 'PERMISSION_DENIED'))
# Check whether is domain admin.
is_admin = sql_lib_general.is_domain_admin(domain=domain,
admin=username)
if is_admin:
return func(*args, **kw)
else:
if settings.LOG_PERMISSION_DENIED:
logger.error("PERMISSION_DENIED (2) raised in "
"@require_domain_access: module=%s.py, "
"function=%s(), "
"admin=%s, "
"domain=%s" % (func.__module__, func.__name__, username, domain))
return api_render((False, 'PERMISSION_DENIED'))
return proxyfunc

2705
libs/sqllib/domain.py Normal file

File diff suppressed because it is too large Load Diff

1084
libs/sqllib/general.py Normal file

File diff suppressed because it is too large Load Diff

1108
libs/sqllib/ml.py Normal file

File diff suppressed because it is too large Load Diff

90
libs/sqllib/sqlutils.py Normal file
View File

@@ -0,0 +1,90 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
from typing import Dict
def account_settings_dict_to_string(account_settings: Dict) -> str:
# Convert account setting dict to string.
# - dict: {'var': 'value', 'var2: value2', ...}
# - string: 'var:value;var2:value2;...'
if not account_settings or not isinstance(account_settings, dict):
return ''
for (k, v) in list(account_settings.items()):
if k in ['default_groups',
'default_mailing_lists',
'enabled_services',
'disabled_mail_services',
'disabled_domain_profiles',
'disabled_user_profiles',
'disabled_user_preferences']:
if isinstance(v, (list, tuple, set)):
if isinstance(v, list):
v.sort()
elif isinstance(v, set):
v = list(v)
v.sort()
account_settings[k] = ','.join(v)
else:
# Remove item if value is not a list/tuple/set
account_settings.pop(k)
new_settings = ';'.join(['{}:{}'.format(str(i), j) for (i, j) in list(account_settings.items()) if j])
if new_settings:
new_settings += ';'
return new_settings
def account_settings_string_to_dict(account_settings: str) -> Dict:
# Convert account setting (string, format 'var:value;var2:value2;...', used
# in MySQL/PGSQL backends) to dict.
# - domain.settings
# - mailbox.settings
# Original setting must be a string
if not account_settings:
return {}
new_settings = {}
items = [st for st in account_settings.split(';') if ':' in st]
for item in items:
if item:
(k, v) = item.split(':')
if v:
new_settings[k] = v
# Convert value to proper format (int, string, ...), default is string.
# It will be useful to compare values with converted values.
# If original value is not stored in proper format, key:value pair will
# be removed.
for key in new_settings:
# integer
if key in ['default_user_quota',
'max_user_quota',
'min_passwd_length',
'max_passwd_length',
# settings used to create new domains.
'create_max_domains',
'create_max_users',
'create_max_lists',
'create_max_aliases',
'create_max_quota']:
try:
new_settings[key] = int(new_settings[key])
except:
new_settings.pop(key)
# list
if key in ['enabled_services',
'disabled_mail_services',
'default_groups',
'default_mailing_lists',
'disabled_domain_profiles',
'disabled_user_profiles',
'disabled_user_preferences']:
new_settings[key] = [str(i) for i in new_settings[key].split(',') if i]
return new_settings

2626
libs/sqllib/user.py Normal file

File diff suppressed because it is too large Load Diff

344
libs/sqllib/utils.py Normal file
View File

@@ -0,0 +1,344 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import web
from libs import iredutils
from libs.logger import log_activity
from libs.sqllib import SQLWrap
from libs.sqllib import domain as sql_lib_domain
from libs.sqllib import admin as sql_lib_admin
from libs.sqllib import user as sql_lib_user
from libs.sqllib import alias as sql_lib_alias
from libs.sqllib import ml as sql_lib_ml
from libs.sqllib import general as sql_lib_general
session = web.config.get('_session', {})
def set_account_status(conn,
accounts,
account_type,
enable_account=False):
"""Set account status.
accounts -- an iterable object (list/tuple) filled with accounts.
account_type -- possible value: domain, admin, user, alias, ml
enable_account -- possible value: True, False
"""
if account_type in ['admin', 'user', 'alias', 'maillist', 'ml']:
# email
accounts = [str(v).lower() for v in accounts if iredutils.is_email(v)]
else:
# domain name
accounts = [str(v).lower() for v in accounts if iredutils.is_domain(v)]
if not accounts:
return True,
# 0: disable, 1: enable
account_status = 0
action = 'disable'
if enable_account:
account_status = 1
action = 'active'
if account_type == 'domain':
# handle with function which handles admin privilege
qr = sql_lib_domain.enable_disable_domains(domains=accounts,
action=action)
return qr
elif account_type == 'admin':
# [(<table>, <column-used-for-query>), ...]
table_column_maps = [("admin", "username")]
elif account_type == 'alias':
table_column_maps = [
("alias", "address"),
("forwardings", "address"),
]
elif account_type in ['maillist', 'ml']:
table_column_maps = [("maillists", "address")]
else:
# account_type == 'user'
table_column_maps = [
("mailbox", "username"),
("forwardings", "address"),
]
for (_table, _column) in table_column_maps:
sql_where = '{} IN {}'.format(_column, web.sqlquote(accounts))
try:
conn.update(_table,
where=sql_where,
active=account_status)
except Exception as e:
return False, repr(e)
log_activity(event=action,
msg="{} {}: {}.".format(action.title(), account_type, ', '.join(accounts)))
return True,
def delete_accounts(accounts,
account_type,
keep_mailbox_days=0,
conn=None):
# accounts must be a list/tuple.
# account_type in ['domain', 'user', 'admin', 'alias', 'ml']
if not accounts:
return True,
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
if account_type == 'domain':
qr = sql_lib_domain.delete_domains(domains=accounts,
keep_mailbox_days=keep_mailbox_days,
conn=conn)
return qr
elif account_type == 'user':
sql_lib_user.delete_users(accounts=accounts,
keep_mailbox_days=keep_mailbox_days,
conn=conn)
elif account_type == 'admin':
sql_lib_admin.delete_admins(mails=accounts, conn=conn)
elif account_type == 'alias':
sql_lib_alias.delete_aliases(conn=conn, accounts=accounts)
elif account_type == 'ml':
sql_lib_ml.delete_maillists(conn=conn, accounts=accounts)
return True,
# Search accounts with display name, email.
def search(search_string,
account_type=None,
account_status=None,
conn=None):
"""Return search result in dict.
(True, {
'domain': sql_query_result,
'user': sql_query_result,
...
}
)
"""
sql_vars = {
'search_str': '%%' + search_string + '%%',
'search_str_exclude_domain': '%%' + search_string + '%%@%%',
}
if not account_type:
account_type = ['domain', 'user', 'alias', 'ml', 'admin']
if not account_status:
account_status = ['active', 'disabled']
sql_where_domain_status = ''
sql_where_admin_status = ''
sql_where_user_status = ''
sql_where_ml_status = ''
sql_where_alias_status = ''
sql_where_user_domain = ''
sql_where_alias_domain = ''
sql_where_ml_domain = ''
if 'active' in account_status and 'disabled' in account_status:
pass
elif 'active' in account_status:
sql_where_domain_status = ' AND domain.active=1'
sql_where_admin_status = ' AND domain.active=1'
sql_where_user_status = ' AND mailbox.active=1'
sql_where_alias_status = ' AND alias.active=1'
sql_where_ml_status = ' AND maillists.active=1'
elif 'disabled' in account_status:
sql_where_domain_status = ' AND domain.active=0'
sql_where_admin_status = ' AND domain.active=0'
sql_where_user_status = ' AND mailbox.active=0'
sql_where_alias_status = ' AND alias.active=0'
sql_where_ml_status = ' AND maillists.active=0'
if not conn:
_wrap = SQLWrap()
conn = _wrap.conn
# Get managed domains.
if not session.get('is_global_admin'):
qr = sql_lib_admin.get_managed_domains(admin=session.get('username'),
domain_name_only=True,
listed_only=True,
conn=conn)
if qr[0]:
managed_domains = qr[1]
sql_where_user_domain = ' AND mailbox.domain IN %s' % web.sqlquote(managed_domains)
sql_where_alias_domain = ' AND alias.domain IN %s' % web.sqlquote(managed_domains)
sql_where_ml_domain = ' AND maillists.domain IN %s' % web.sqlquote(managed_domains)
else:
raise web.seeother('/search?msg=%s' % web.urlquote(qr[1]))
result = {
'domain': [],
'admin': [],
'user': [],
'ml': [],
'last_logins': {},
'user_alias_addresses': {},
'user_forwarding_addresses': {},
'user_assigned_groups': {},
'alias': [],
# List of email addresses of global admins.
'allGlobalAdmins': [],
}
if session.get('is_global_admin'):
if 'domain' in account_type:
qr_domain = conn.select(
'domain',
vars=sql_vars,
what='domain,description,aliases,mailboxes,maxquota,active',
where='(domain LIKE $search_str OR description LIKE $search_str) %s' % sql_where_domain_status,
order='domain',
)
if qr_domain:
result['domain'] = iredutils.bytes2str(qr_domain)
if 'admin' in account_type:
qr_admin = conn.select(
'admin',
vars=sql_vars,
what='username,name,active',
where='(username LIKE $search_str OR name LIKE $search_str) %s' % sql_where_admin_status,
order='username',
)
if qr_admin:
result['admin'] = iredutils.bytes2str(qr_admin) or []
# Get all global admin accounts.
qr = sql_lib_admin.get_all_global_admins(conn=conn)
if qr[0]:
result['allGlobalAdmins'] = qr[1]
# Search user accounts.
if 'user' in account_type:
search_str_user = sql_vars['search_str_exclude_domain']
if '@' in sql_vars['search_str']:
search_str_user = sql_vars['search_str']
sql_vars['search_str_user'] = search_str_user
# Query users by email address or display name
qr_user = conn.select(
'mailbox',
vars=sql_vars,
what='username,name,quota,employeeid,active',
where='(username LIKE $search_str_user OR name LIKE $search_str) {} {}'.format(sql_where_user_status, sql_where_user_domain),
order='username')
# Query users by per-user alias address
qr_user_alias = conn.select(
['forwardings', 'mailbox'],
vars=sql_vars,
what='mailbox.username, mailbox.name, mailbox.quota, mailbox.employeeid, mailbox.active',
where='(forwardings.address LIKE $search_str_user) AND forwardings.forwarding=mailbox.username AND forwardings.is_alias=1 {} {}'.format(sql_where_user_status, sql_where_user_domain),
group='mailbox.username, mailbox.name, mailbox.quota, mailbox.employeeid, mailbox.active',
order='mailbox.username')
# Query users by mail forwarding address
qr_user_forwarding = conn.select(
['forwardings', 'mailbox'],
vars=sql_vars,
what='mailbox.username, mailbox.name, mailbox.quota, mailbox.employeeid, mailbox.active',
where='(forwardings.forwarding LIKE $search_str_user) AND forwardings.address=mailbox.username AND forwardings.is_forwarding=1 {} {}'.format(sql_where_user_status, sql_where_user_domain),
group='mailbox.username, mailbox.name, mailbox.quota, mailbox.employeeid, mailbox.active',
order='mailbox.username')
if qr_user:
result['user'] += iredutils.bytes2str(qr_user)
if qr_user_alias:
_records = iredutils.bytes2str(qr_user_alias)
# Add new, remove duplicate records.
for i in _records:
if not (i in result['user']):
result['user'] += [i]
if qr_user_forwarding:
_records = iredutils.bytes2str(qr_user_forwarding)
# Add new, remove duplicate records.
for i in _records:
if not (i in result['user']):
result['user'] += [i]
# Get email addresses of returned user accounts
_user_emails = []
for i in result['user']:
_user_emails.append(str(i['username']).lower())
_user_emails.sort()
# Get per-user alias and mail forwarding addresses
if _user_emails:
(_status, _result) = sql_lib_user.get_bulk_user_alias_addresses(mails=_user_emails, conn=conn)
if _status:
result['user_alias_addresses'] = _result
(_status, _result) = sql_lib_user.get_bulk_user_forwardings(mails=_user_emails, conn=conn)
if _status:
result['user_forwarding_addresses'] = _result
(_status, _result) = sql_lib_user.get_bulk_user_assigned_groups(mails=_user_emails, conn=conn)
if _status:
result['user_assigned_groups'] = _result
# Get user last login
result['last_logins'] = sql_lib_general.get_account_last_login(accounts=_user_emails, conn=conn)
# Search alias accounts.
if 'alias' in account_type:
search_str_alias = sql_vars['search_str_exclude_domain']
if '@' in sql_vars['search_str']:
search_str_alias = sql_vars['search_str']
sql_vars['search_str_alias'] = search_str_alias
qr_alias = conn.select(
'alias',
vars=sql_vars,
what='address,name,accesspolicy,domain,active',
where='(address LIKE $search_str_alias OR name LIKE $search_str) {} {}'.format(
sql_where_alias_status, sql_where_alias_domain,
),
order='address',
)
if qr_alias:
result['alias'] = iredutils.bytes2str(qr_alias) or []
# Search mailing list accounts.
if 'ml' in account_type:
search_str_ml = sql_vars['search_str_exclude_domain']
if '@' in sql_vars['search_str']:
search_str_ml = sql_vars['search_str']
sql_vars['search_str_ml'] = search_str_ml
qr_ml = conn.select(
'maillists',
vars=sql_vars,
what='address,name,accesspolicy,domain,active',
where='(address LIKE $search_str_alias OR name LIKE $search_str) {} {}'.format(
sql_where_ml_status, sql_where_ml_domain,
),
order='address',
)
if qr_ml:
result['ml'] = iredutils.bytes2str(qr_ml) or []
if result:
return True, result
else:
return False, []

230
libs/sysinfo.py Normal file
View File

@@ -0,0 +1,230 @@
# Author: Zhang Huangbin <zhb@iredmail.org>
import os
import urllib.request
import urllib.error
import urllib.parse
import socket
import platform
import web
from os import getloadavg
import time
import simplejson as json
from libs.logger import log_traceback
import settings
if settings.backend == "ldap":
from libs import __version_ldap__ as __version__
else:
from libs import __version_sql__ as __version__
__id__ = "meow"
session = web.config.get("_session")
def get_iredmail_version():
v = "Unknown, check /etc/iredmail-release please."
# Read first word splited by space in first line.
try:
f = open("/etc/iredmail-release")
vline = f.readline().split()
f.close()
if vline:
v = vline[0]
except:
pass
return v
def __get_proxied_urlopen():
socket.setdefaulttimeout(5)
if settings.HTTP_PROXY:
# urllib2 adds proxy handlers with environment variables automatically
os.environ["http_proxy"] = settings.HTTP_PROXY
os.environ["https_proxy"] = settings.HTTP_PROXY
return urllib.request.urlopen
def get_license_info():
return True, {
"status": "active",
"product": "iRedAdmin-Pro-SQL",
"licensekey": "open-source",
"upgradetutorials": "https://docs.iredmail.org/iredadmin-pro.releases.html",
"purchased": "Never",
"contacts": "Your sys-admin",
"latestversion": "5.3",
"expired": "Never",
"releasenotes": "https://docs.iredmail.org/iredadmin-pro.releases.html",
"id": __id__
}
# if len(__id__) != 32:
# web.conn_iredadmin.delete("updatelog")
# session.kill()
# raise web.seeother("/login?msg=INVALID_PRODUCT_ID")
# params = {
# "v": __version__,
# "f": __id__,
# "lang": settings.default_language,
# "host": get_hostname(),
# "backend": settings.backend,
# "webmaster": settings.webmaster,
# "mac": ",".join(get_all_mac_addresses()),
# }
# url = "https://lic.iredmail.org/check_version/licenseinfo/" + __id__ + ".json"
# url += "?" + urllib.parse.urlencode(params)
# try:
# urlopen = __get_proxied_urlopen()
# _json = urlopen(url).read()
# lic_info = json.loads(_json)
# lic_info["id"] = __id__
# return True, lic_info
# except Exception as e:
# return False, web.urlquote(e)
def check_new_version():
"""Check new version.
Return (None, None) if no new version available.
Return (False, <error>) if any error while checking.
Return (True, <new_version_number>) if new version available.
"""
try:
today = time.strftime("%Y-%m-%d")
sql_vars = {"today": today}
# Check whether we already checked new version today
r = web.conn_iredadmin.select("updatelog", vars=sql_vars, where="date=$today", limit=1)
if not r:
qr = get_license_info()
# Always remove all old records, just keep the last one.
web.conn_iredadmin.delete("updatelog", vars=sql_vars, where="date < $today")
if qr[0]:
if __version__ >= qr[1]["latestversion"]:
# Insert updating date if no new version available.
web.conn_iredadmin.insert("updatelog", date=today)
else:
return True, qr[1]["latestversion"]
except Exception as e:
return False, repr(e)
return None, None
def get_hostname():
_hostname = ""
try:
_hostname = socket.getfqdn()
except:
try:
_hostname = platform.node()
except:
pass
return _hostname
def get_server_uptime():
try:
# Works on Linux.
f = open("/proc/uptime")
contents = f.read().split()
f.close()
except:
return None
total_seconds = float(contents[0])
# convert to seconds
_minute_secs = 60
_hour_secs = _minute_secs * 60
_day_secs = _hour_secs * 24
# Get the days, hours, minutes.
days = int(total_seconds / _day_secs)
hours = int((total_seconds % _day_secs) / _hour_secs)
minutes = int((total_seconds % _hour_secs) / _minute_secs)
return days, hours, minutes
def get_system_load_average():
try:
(a1, a2, a3) = getloadavg()
a1 = "%.3f" % a1
a2 = "%.3f" % a2
a3 = "%.3f" % a3
return a1, a2, a3
except:
log_traceback()
return 0, 0, 0
def get_nic_info():
# Return list of basic info of available network interfaces.
# Format: [(name, ip_address, netmask), ...]
# Sample: [('eth0', '192.168.1.1', '255.255.255.0'), ...]
netif_data = []
try:
import netifaces
except:
return netif_data
try:
ifaces = netifaces.interfaces()
for iface in ifaces:
if iface in ["lo", "lo0"]:
# `lo` -> Linux
# `lo0` -> OpenBSD
continue
try:
addr = netifaces.ifaddresses(iface)
for af in addr:
if af in (netifaces.AF_INET, netifaces.AF_INET6):
for item in addr[af]:
netif_data.append(
(iface, item.get("addr", ""), item.get("netmask", ""))
)
except:
log_traceback()
except:
log_traceback()
return netif_data
def get_all_mac_addresses():
"""
Get list of hardware MAC addresses of all network interfaces.
Return a list of addresses.
"""
mac_addresses = []
try:
for (_iface, _addr, _netmask) in get_nic_info():
if _iface != "lo":
mac_addresses.append(_addr)
except:
pass
return mac_addresses