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

1590
ChangeLog.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,22 @@
# iRedAdmin-Pro-SQL
* Please read file 'EULA' for End User License Agreement.
### Free & open-source repository of iRedAdmin-Pro-SQL, for everyone to enjoy <3
* If you already have iRedAdmin open source edition or old iRedAdmin-Pro
release installed, please follow below tutorial to upgrade it to the latest
iRedAdmin-Pro, it's the easiest way with minimal steps:
https://docs.iredmail.org/migrate.or.upgrade.iredadmin.html
Only very few files were changed. Original check has been commented out so you can understand what it did before.
* Release Notes:
https://docs.iredmail.org/iredadmin-pro.releases.html
```console
- controllers/panel/sys_settings.py
# This script did the actual check
* iRedAdmin-Pro RESTful API interface:
https://docs.iredmail.org/iredadmin-pro.restful.api.html
- templates/default/panel/license.html
# Tiny change to remove the "Renew License" button
* iRedAdmin-Pro documentations:
https://docs.iredmail.org/#iredadmin
- static/default/css/screen.css
# ctrl+shift+I formatting & changed color from green to purple. CSS file looks disgusting, refusing to clean that
```
* Report bugs/issues in our online support forum:
https://forum.iredmail.org/
Thats it
<br><br>
-----
### Original Details
|Feature | iRedAdmin (OSE) | iRedAdmin-Pro|
|------------------------------------|-----------------|--------------|
$\textcolor{orange}{\textsf{Localized Web Interface}}$<br>English, German, Spanish, French, Italian, Polish, Chinese, and more. | X | X |
$\textcolor{orange}{\textsf{RESTful API Interface}}$<br>Read our [API documentation](https://docs.iredmail.org/iredadmin-pro.restful.api.html) | | X |
$\textcolor{orange}{\textsf{Unlimited Mail Domains}}$<br>Host as many mail domains as you want | X | X |
$\textcolor{orange}{\textsf{Unlimited Mail Users}}$<br>With per-user mailbox quota control | X | X |
$\textcolor{orange}{\textsf{Unlimited Mailing List/Aliases}}$<br>Manage members, access policies | | X |
$\textcolor{orange}{\textsf{Unlimited Domain-Level Admins}}$<br>Either promote a mail user to domain admin role, or create a separated domain admin account | | X |
$\textcolor{orange}{\textsf{Advanced Domain Management}}$<br>Domain-level mailbox quota, limit numbers of user/list/alias accounts, Relay, BCC, Alias, Domain, Catch-all, Backup MX, Throttling, Greylisting, Whitelists, Blacklists, Spam Policy, user password length and complexity control | | X |
$\textcolor{orange}{\textsf{Advanced User Management}}$<br>Per-user BCC, Relay, Mail Forwarding, Alias Addresses, Throttling, Greylisting, Whitelists, Blacklists, Spam Policy, restrict login IP/network, Changing email address | | X |
$\textcolor{orange}{\textsf{Self-Service}}$<br>Allow end user to manage their own preferences: Password, Mail Forwarding, Whitelists, Blacklists, Quarantined Mails, Spam Policy | | X |
$\textcolor{orange}{\textsf{Service Control}}$<br>One click to enable/disable mail services for mail user: POP3, IMAP, SMTP, Sieve filter, Mail Forwarding, BCC, and more. | | X |
$\textcolor{orange}{\textsf{Spam/Virus Quarantining}}$<br>Quarantine detected SPAM/Virus into SQL PostgreSQL database for later management (delete, release, whitelist, blacklist) | | X |
$\textcolor{orange}{\textsf{View basic info of all sent and received emails}}$<br>Sender, Recipient, Subject, Spam Score, Size, Date | | X |
$\textcolor{orange}{\textsf{Throttling}}$<br>Based on: max size of single email, number of max inbound/outbound emails, cumulative size of all inbound/outbound emails | | X |
$\textcolor{orange}{\textsf{Whitelisting, Blacklisting}}$<br>Based on: IP addresses/networks, Sender address, Sender domain name | | X |
$\textcolor{orange}{\textsf{Searching Account}}$<br>Searching with display name or email address, domain name | | X |
$\textcolor{orange}{\textsf{Log Maildir Path of Deleted Dail User}}$<br>You can delete the mailbox on file system later, either manually or with a cron job | | X |
$\textcolor{orange}{\textsf{Log Admin Activities}}$<br>Account creation, activation, removal, password change, and more. | | X |
$\textcolor{orange}{\textsf{Fail2ban Integration}}$<br>View info of banned IP address (Country/City, reverse DNS name), log lines which triggerred the ban (easy to troubleshoot why the ban happened), and unban it with one click | | X |
$\textcolor{orange}{\textsf{Last login track}}$<br>View the time of user last login via IMAP and POP3 services, also the time of last (locally) delivered email | | X |
$\textcolor{orange}{\textsf{Export all managed mail accounts}}$<br>Export statistics of admins| | X |
-----
![2023-04-10-064957](https://user-images.githubusercontent.com/104512346/230828290-cf3aec7c-a850-494a-94f9-0f739ffc6b48.png)
If you found any bug, typo in iRedAdmin-Pro source code, or you want to request
a new feature, please don't hesitate to contact us: support@iredmail.org.
Your feedback is greatly appreciated.

147
SQL/iredadmin.mysql Normal file
View File

@@ -0,0 +1,147 @@
-- CREATE DATABASE iredadmin DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;
-- GRANT INSERT,UPDATE,DELETE,SELECT on iredadmin.* to iredadmin@localhost identified by 'secret_passwd';
-- USE iredadmin;
--
-- Session table required by webpy session module.
--
CREATE TABLE IF NOT EXISTS `sessions` (
`session_id` CHAR(128) UNIQUE NOT NULL,
`atime` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`data` TEXT
) ENGINE=InnoDB;
--
-- Store all admin operations.
--
CREATE TABLE IF NOT EXISTS `log` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT,
`timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`admin` VARCHAR(255) NOT NULL,
`ip` VARCHAR(40) NOT NULL,
`domain` VARCHAR(255) NOT NULL DEFAULT '',
`username` VARCHAR(255) NOT NULL DEFAULT '',
`event` VARCHAR(20) NOT NULL DEFAULT '',
`loglevel` VARCHAR(10) NOT NULL DEFAULT 'info',
`msg` TEXT,
PRIMARY KEY (id),
INDEX (timestamp),
INDEX (admin),
INDEX (ip),
INDEX (domain),
INDEX (username),
INDEX (event),
INDEX (loglevel)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `updatelog` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT,
`date` DATE NOT NULL,
PRIMARY KEY (id),
INDEX (date)
) ENGINE=InnoDB;
-- Used to store basic info of deleted mailboxes.
CREATE TABLE IF NOT EXISTS `deleted_mailboxes` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT,
`timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Email address of deleted user
`username` VARCHAR(255) NOT NULL DEFAULT '',
-- Domain part of user email address
`domain` VARCHAR(255) NOT NULL DEFAULT '',
-- Absolute path of user's mailbox
`maildir` VARCHAR(255) NOT NULL DEFAULT '',
-- Which domain admin deleted this user
`admin` VARCHAR(255) NOT NULL DEFAULT '',
-- The time scheduled to delete this mailbox.
-- NOTE: it requires cron job + script to actually delete the mailbox.
delete_date DATE DEFAULT NULL,
PRIMARY KEY (id),
INDEX (timestamp),
INDEX (username),
INDEX (domain),
INDEX (admin),
INDEX (delete_date)
) ENGINE=InnoDB;
-- Key-value store.
CREATE TABLE IF NOT EXISTS `tracking` (
id BIGINT(20) UNSIGNED AUTO_INCREMENT,
`k` VARCHAR(255) NOT NULL,
`v` TEXT,
`time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY (k)
) ENGINE=InnoDB;
-- Store admin <-> domain <-> verify_code used to verify domain ownership
CREATE TABLE IF NOT EXISTS domain_ownership (
id BIGINT(20) UNSIGNED AUTO_INCREMENT,
-- the admin who added this domain with iRedAdmin. Required if domain was
-- added by a normal domain admin.
admin VARCHAR(255) NOT NULL DEFAULT '',
-- the domain we're going to verify. If we're going to verifying an alias
-- domain, it stores primary domain.
domain VARCHAR(255) NOT NULL DEFAULT '',
-- if we're verifying an alias domain:
-- - store primary domain in `domain`
-- - store alias domain in `alias_domain`
alias_domain VARCHAR(255) NOT NULL DEFAULT '',
-- a unique string which domain admin should put in TXT type DNS record
-- or as a web file on web server
verify_code VARCHAR(100) NOT NULL DEFAULT '',
-- store the verify status
verified TINYINT(1) NOT NULL DEFAULT 0,
-- store error message if any returned while verifying, so that domain
-- admin can fix it
message TEXT,
-- the last time we verify it. If it's verified, this record will be
-- removed in 1 month.
last_verify TIMESTAMP NULL DEFAULT NULL,
-- expire time. cron job `tools/cleanup_db.py` will remove verified or
-- unverified domains regularly. e.g. one month.
-- Note: stores seconds since Unix epoch
expire INT UNSIGNED DEFAULT 0,
PRIMARY KEY (id),
UNIQUE INDEX (admin, domain, alias_domain),
INDEX (verified)
) ENGINE=InnoDB;
-- mailing list subscription/unsubscription confirms.
CREATE TABLE IF NOT EXISTS `newsletter_subunsub_confirms` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT,
-- email of mailing list
`mail` VARCHAR(255) NOT NULL DEFAULT '',
-- unique server wide id
`mlid` VARCHAR(255) NOT NULL DEFAULT '',
-- email of subscriber
`subscriber` VARCHAR(255) NOT NULL DEFAULT '',
-- kinds of 'subscribe', 'unsubscribe'
`kind` VARCHAR(20) NOT NULL DEFAULT '',
-- unique server-wide id as confirm token
`token` VARCHAR(255) NOT NULL DEFAULT '',
`expired` INT UNSIGNED DEFAULT 0,
PRIMARY KEY (id),
INDEX (mail),
UNIQUE INDEX (mlid, subscriber, kind),
INDEX (token),
INDEX (expired)
) ENGINE=InnoDB;
-- Key-value store for settings.
-- `k` is the (unique) parameter name.
-- `v` must be a valid JSON string with only one key: "value". Its value will
-- be converted to Python native format (string, list, integer).
-- Samples:
-- {"value": 20}
-- {"value": "a-string"}
-- {"value": [v1, v2, v3, ...]}
-- {"value": true}
CREATE TABLE IF NOT EXISTS `settings` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT,
`account` VARCHAR(255) NOT NULL DEFAULT 'global',
`k` VARCHAR(255) NOT NULL,
`v` TEXT,
PRIMARY KEY (id),
UNIQUE INDEX (account, k)
) ENGINE=InnoDB;

116
SQL/iredadmin.pgsql Normal file
View File

@@ -0,0 +1,116 @@
-- CREATE DATABASE iredadmin WITH TEMPLATE template0 ENCODING 'UTF8';
-- CREATE ROLE iredadmin WITH LOGIN ENCRYPTED PASSWORD 'plain_password' NOSUPERUSER NOCREATEDB NOCREATEROLE;
-- \c iredadmin;
-- Session table required by webpy session module.
CREATE TABLE sessions (
session_id CHAR(128) UNIQUE NOT NULL,
atime TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
data TEXT
);
-- Store all admin operations.
CREATE TABLE log (
id SERIAL PRIMARY KEY,
admin VARCHAR(255) NOT NULL,
timestamp TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
ip VARCHAR(40) NOT NULL,
domain VARCHAR(255) NOT NULL DEFAULT '',
username VARCHAR(255) NOT NULL DEFAULT '',
event VARCHAR(20) NOT NULL DEFAULT '',
loglevel VARCHAR(10) NOT NULL DEFAULT 'info',
msg TEXT
);
CREATE INDEX idx_log_timestamp ON log (timestamp);
CREATE INDEX idx_log_ip ON log (ip);
CREATE INDEX idx_log_domain ON log (domain);
CREATE INDEX idx_log_username ON log (username);
CREATE INDEX idx_log_event ON log (event);
CREATE INDEX idx_log_loglevel ON log (loglevel);
CREATE TABLE updatelog (
date DATE NOT NULL,
PRIMARY KEY (date)
);
-- GRANT INSERT,UPDATE,DELETE,SELECT on sessions,log,updatelog to iredadmin;
-- GRANT UPDATE,USAGE,SELECT ON log_id_seq TO iredadmin;
-- Key-value store.
CREATE TABLE tracking (
id SERIAL PRIMARY KEY,
k VARCHAR(255) NOT NULL,
v TEXT,
time TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_tracking_k ON tracking (k);
-- Store <-> domain <-> verify_code used to verify domain ownership
CREATE TABLE domain_ownership (
id SERIAL PRIMARY KEY,
-- the admin who added this domain with iRedAdmin. Required if domain was
-- added by a normal domain admin.
admin VARCHAR(255) NOT NULL DEFAULT '',
-- the domain we're going to verify. If we're going to verifying an alias
-- domain, it stores primary domain.
domain VARCHAR(255) NOT NULL DEFAULT '',
-- if we're verifying an alias domain:
-- - store primary domain in `domain`
-- - store alias domain in `alias_domain`
alias_domain VARCHAR(255) NOT NULL DEFAULT '',
-- a unique string which domain admin should put in TXT type DNS record
-- or as a web file on web server
verify_code VARCHAR(100) NOT NULL DEFAULT '',
-- store the verify status
verified INT2 NOT NULL DEFAULT 0,
-- store error message if any returned while verifying, so that domain
-- admin can fix it
message TEXT,
-- the last time we verify it. If it's verified, this record will be
-- removed in 1 month.
last_verify TIMESTAMP NULL DEFAULT NULL,
-- expire time. cron job `tools/cleanup_db.py` will remove verified or
-- unverified domains regularly. e.g. one month.
-- Note: stores seconds since Unix epoch
expire INT DEFAULT 0
);
CREATE UNIQUE INDEX idx_ownership_1 ON domain_ownership (admin, domain, alias_domain);
CREATE INDEX idx_ownership_2 ON domain_ownership (verified);
-- mailing list subscription/unsubscription confirms.
CREATE TABLE newsletter_subunsub_confirms (
id SERIAL PRIMARY KEY,
-- email of mailing list
mail VARCHAR(255) NOT NULL DEFAULT '',
-- unique server wide id
mlid VARCHAR(255) NOT NULL DEFAULT '',
-- email of subscriber
subscriber VARCHAR(255) NOT NULL DEFAULT '',
-- kinds of 'subscribe', 'unsubscribe'
kind VARCHAR(20) NOT NULL DEFAULT '',
-- unique server-wide id as confirm token
token VARCHAR(255) NOT NULL DEFAULT '',
expired INT DEFAULT 0
);
CREATE UNIQUE INDEX idx_subunsub_confirms_1 ON newsletter_subunsub_confirms (mlid, subscriber, kind);
CREATE INDEX idx_subunsub_confirms_2 ON newsletter_subunsub_confirms (mail);
CREATE INDEX idx_subunsub_confirms_3 ON newsletter_subunsub_confirms (token);
CREATE INDEX idx_subunsub_confirms_4 ON newsletter_subunsub_confirms (expired);
-- Key-value store for settings.
-- `k` is the (unique) parameter name.
-- `v` must be a valid JSON string with only one key: "value". Its value will
-- be converted to Python native format (string, list, integer).
-- Samples:
-- {"value": 20}
-- {"value": "a-string"}
-- {"value": [v1, v2, v3, ...]}
-- {"value": true}
CREATE TABLE settings (
id SERIAL PRIMARY KEY,
account VARCHAR(255) NOT NULL DEFAULT 'global',
k VARCHAR(255) NOT NULL,
v TEXT
);
CREATE UNIQUE INDEX idx_settings_account_k ON settings (account, k);

View File

@@ -0,0 +1,20 @@
-- mailing list subscription/unsubscription confirms.
CREATE TABLE IF NOT EXISTS `newsletter_subunsub_confirms` (
`id` BIGINT(20) UNSIGNED AUTO_INCREMENT,
-- email of mailing list
`mail` VARCHAR(255) NOT NULL DEFAULT '',
-- unique server wide id
`mlid` VARCHAR(255) NOT NULL DEFAULT '',
-- email of subscriber
`subscriber` VARCHAR(255) NOT NULL DEFAULT '',
-- kinds of 'subscribe', 'unsubscribe'
`kind` VARCHAR(20) NOT NULL DEFAULT '',
-- unique server-wide id as confirm token
`token` VARCHAR(255) NOT NULL DEFAULT '',
`expired` INT UNSIGNED DEFAULT 0,
PRIMARY KEY (id),
INDEX (mail),
UNIQUE INDEX (mlid, subscriber, kind),
INDEX (token),
INDEX (expired)
) ENGINE=InnoDB;

View File

@@ -0,0 +1,19 @@
-- mailing list subscription/unsubscription confirms.
CREATE TABLE newsletter_subunsub_confirms (
id SERIAL PRIMARY KEY,
-- email of mailing list
mail VARCHAR(255) NOT NULL DEFAULT '',
-- unique server wide id
mlid VARCHAR(255) NOT NULL DEFAULT '',
-- email of subscriber
subscriber VARCHAR(255) NOT NULL DEFAULT '',
-- kinds of 'subscribe', 'unsubscribe'
kind VARCHAR(20) NOT NULL DEFAULT '',
-- unique server-wide id as confirm token
token VARCHAR(255) NOT NULL DEFAULT '',
expired INT DEFAULT 0
);
CREATE UNIQUE INDEX idx_subunsub_confirms_1 ON newsletter_subunsub_confirms (mlid, subscriber, kind);
CREATE INDEX idx_subunsub_confirms_2 ON newsletter_subunsub_confirms (mail);
CREATE INDEX idx_subunsub_confirms_3 ON newsletter_subunsub_confirms (token);
CREATE INDEX idx_subunsub_confirms_4 ON newsletter_subunsub_confirms (expired);

View File

@@ -0,0 +1,7 @@
CREATE TABLE settings (
id SERIAL PRIMARY KEY,
account VARCHAR(255) NOT NULL DEFAULT 'global',
k VARCHAR(255) NOT NULL,
v TEXT
);
CREATE UNIQUE INDEX idx_settings_account_k ON settings (account, k);

3
i18n/babel.cfg Normal file
View File

@@ -0,0 +1,3 @@
[jinja2: templates/**.html]
encoding = utf-8
line_statement_prefix = %

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

2769
i18n/iredadmin.po Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

105
i18n/translation.sh Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Author: Zhang Huangbin (zhb _at_ iredmail.org)
#---------------------------------------------------------------------
# This file is part of iRedAdmin-Pro, which is official web-based admin
# panel (Full-Featured Edition) for iRedMail.
#
# ---- Restrictions ----
# * Source code is only available after you purchase it, so that you can
# modify it to fit your need, but it is NOT allowed to redistribute
# and sell iRedAdmin and the one you modified based on iRedAdmin.
#
# * We will do our best to solve all bugs found in official iRedAdmin,
# but we are not guarantee to solve bugs occured in your modified copy.
#
# * It is NOT allowed to deployed on more than 1 server.
#
#---------------------------------------------------------------------
# Available actions: [all, LANG].
ACTIONORLANG="$1"
if [ -z "${ACTIONORLANG}" ]; then
cat <<EOF
Usage: $0 [all, LANGUAGE]
Example:
$ $0 all
$ $0 zh_CN
$ $0 fr_Fr
EOF
exit 255
fi
DOMAIN="iredadmin"
POFILE="${DOMAIN}.po"
#AVAILABLE_LANGS="$(ls -d *_*)"
AVAILABLE_LANGS="$(ls -ld * | awk '/^d/ {print $NF}')"
extract_latest()
{
# Extract strings from template files.
echo "* Extract localizable messages from template files to ${POFILE}..."
pybabel extract \
-F babel.cfg \
--no-location \
--omit-header \
--sort-output \
--charset=utf-8 \
--msgid-bugs-address=support@iredmail.org \
-o ${POFILE} \
.. >/dev/null
}
update_po()
{
# Update PO files.
echo "* Updating existing translations ..."
for lang in ${LANGUAGES}
do
pofile="${lang}/LC_MESSAGES/${DOMAIN}.po"
[ -d ${lang}/LC_MESSAGES/ ] || mkdir -p ${lang}/LC_MESSAGES/
pybabel update --ignore-obsolete\
-i ${POFILE} \
-D ${DOMAIN} \
-d . \
-l ${lang}
# Remove 'fuzzy' tag.
perl -pi -e 's/#, fuzzy//' ${pofile}
# Comment ', python-format'.
perl -pi -e 's/^(, python-format.*)/#${1}/' ${pofile}
# Update 'Project-Id-Version'
perl -pi -e 's#^("Project-Id-Version:).*#${1} iRedAdmin-Pro\\n"#g' ${pofile}
perl -pi -e 's#^("POT-Creation-Date:.*\n)##g' ${pofile}
perl -pi -e 's#^("Report-Msgid-Bugs-To:.*\n)##g' ${pofile}
done
}
convert_po_to_mo()
{
for lang in ${LANGUAGES}; do
echo " + Converting ${lang} ..."
msgfmt --statistics --check-format ${lang}/LC_MESSAGES/${DOMAIN}.po -o ${lang}/LC_MESSAGES/${DOMAIN}.mo
done
}
if [ X"${ACTIONORLANG}" == X"all" -o X"${ACTIONORLANG}" == X"" ]; then
export LANGUAGES="${AVAILABLE_LANGS}"
else
export LANGUAGES="$(basename ${ACTIONORLANG})"
fi
extract_latest && \
update_po && \
convert_po_to_mo

Binary file not shown.

File diff suppressed because it is too large Load Diff

19
iredadmin.py Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python
# Author: Zhang Huangbin <zhb@iredmail.org>
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
from libs import iredbase
# Initialize webpy app.
app = iredbase.app
if __name__ == "__main__":
# Starting webpy builtin http server.
# WARNING: this should not be used for production.
app.run()
else:
# Run as a WSGI application
application = app.wsgifunc()

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

32
requirements.txt Normal file
View File

@@ -0,0 +1,32 @@
# The core Python 3 micro web framework: https://webpy.org/
web.py>=0.61
# HTML template engine.
Jinja2>=2.2.0
# LDAP driver.
python-ldap>=3.3.1
# MySQL/MariaDB driver.
PyMySQL>=0.9.3
# PostgreSQL driver.
psycopg2
requests>=2.10.0
# DNS queries.
dnspython
# Get info of network interfaces.
netifaces
# bcrypt password hash.
bcrypt
# Required by Python 3.5 and LDAP backend.
#
# Use `simplejson` instead of the Python builtin `json`, because `json` doesn't
# support serializing bytes (mostly used by LDAP backend) and raise error
# `Object of type 'bytes' is not JSON serializable`.
simplejson

113
settings.py.mysql.sample Normal file
View File

@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
#
###############################################################
# DO NOT MODIFY THIS LINE, IT'S USED TO IMPORT DEFAULT SETTINGS.
from libs.default_settings import *
###############################################################
# General settings.
#
# Site webmaster's mail address.
webmaster = 'zhb@iredmail.org'
# Default language.
default_language = 'en_US'
# Database backend: mysql.
backend = 'mysql'
# Directory used to store mailboxes. Defaults to /var/vmail/vmail1.
# Note: This directory must be owned by 'vmail:vmail' with permission 0700.
storage_base_directory = '/var/vmail/vmail1'
# Default mta transport.
# There're 3 transports available in iRedMail:
#
# 1. dovecot: default LDA transport. Supported by all iRedMail releases.
# 2. lmtp:unix:private/dovecot-lmtp: LMTP (socket listener). Supported by
# iRedMail-0.8.6 and later releases.
# 3. lmtp:inet:127.0.0.1:24: LMTP (TCP listener). Supported by iRedMail-0.8.6
# and later releases.
#
# Note: You can set per-domain or per-user transport in account profile page.
default_mta_transport = 'dovecot'
# Min/Max admin password length. 0 means unlimited.
# - min_passwd_length: at least 1 character is required.
# Normal admin can not set shorter/longer password lengths than global settings
# defined here.
min_passwd_length = 8
max_passwd_length = 0
#####################################################################
# Database used to store iRedAdmin data. e.g. sessions, log.
#
iredadmin_db_host = '127.0.0.1'
iredadmin_db_port = 3306
iredadmin_db_name = 'iredadmin'
iredadmin_db_user = 'iredadmin'
iredadmin_db_password = 'password'
############################################
# Database used to store mail accounts.
#
vmail_db_host = '127.0.0.1'
vmail_db_port = 3306
vmail_db_name = 'vmail'
vmail_db_user = 'vmailadmin'
vmail_db_password = 'password'
##############################################################################
# Settings used for Amavisd-new integration. Provides spam/virus quaranting,
# releasing, etc.
#
# Log basic info of in/out emails into SQL (@storage_sql_dsn): True, False.
# It's @storage_sql_dsn setting in amavisd. You can find this setting
# in amavisd-new config files:
# - On RHEL/CentOS: /etc/amavisd.conf or /etc/amavisd/amavisd.conf
# - On Debian/Ubuntu: /etc/amavis/conf.d/50-user.conf
# - On FreeBSD: /usr/local/etc/amavisd.conf
amavisd_enable_logging = True
amavisd_db_host = '127.0.0.1'
amavisd_db_port = 3306
amavisd_db_name = 'amavisd'
amavisd_db_user = 'amavisd'
amavisd_db_password = 'password'
# #### Quarantining ####
# Release quarantined SPAM/Virus mails: True, False.
# iRedAdmin-Pro will connect to @amavisd_db_host to release quarantined mails.
# How to enable quarantining in Amavisd-new:
# http://www.iredmail.org/docs/quarantining.html
amavisd_enable_quarantine = True
# Port of Amavisd protocol 'AM.PDP-INET'. Default is 9998.
# If Amavisd is not running on database server specified in amavisd_db_host,
# please set the server address in parameter `AMAVISD_QUARANTINE_HOST`.
# Default is '127.0.0.1'. Sample setting:
#AMAVISD_QUARANTINE_HOST = '192.168.1.1'
amavisd_quarantine_port = 9998
# Enable per-recipient spam policy, white/blacklist.
amavisd_enable_policy_lookup = True
##############################################################################
# Settings used for iRedAPD integration. Provides throttling and more.
#
iredapd_enabled = True
iredapd_db_host = '127.0.0.1'
iredapd_db_port = 3306
iredapd_db_name = 'iredapd'
iredapd_db_user = 'iredapd'
iredapd_db_password = 'password'
##############################################################################
# Settings used for mlmmj (mailing list manager) and mlmmjadmin integration.
#
# The API auth token required to access mlmmjadmin API.
mlmmjadmin_api_auth_token = ''
##############################################################################
# Place your custom settings below, you can override all settings in this file
# and libs/default_settings.py here.
#

113
settings.py.pgsql.sample Normal file
View File

@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
#
############################################################
# DO NOT MODIFY THIS LINE, IT'S USED TO IMPORT DEFAULT SETTINGS.
from libs.default_settings import *
############################################################
# General settings.
#
# Site webmaster's mail address.
webmaster = 'zhb@iredmail.org'
# Default language.
default_language = 'en_US'
# Database backend: pgsql.
backend = 'pgsql'
# Directory used to store mailboxes. Defaults to /var/vmail/vmail1.
# Note: This directory must be owned by 'vmail:vmail' with permission 0700.
storage_base_directory = '/var/vmail/vmail1'
# Default mta transport.
# There're 3 transports available in iRedMail:
#
# 1. dovecot: default LDA transport. Supported by all iRedMail releases.
# 2. lmtp:unix:private/dovecot-lmtp: LMTP (socket listener). Supported by
# iRedMail-0.8.6 and later releases.
# 3. lmtp:inet:127.0.0.1:24: LMTP (TCP listener). Supported by iRedMail-0.8.6
# and later releases.
#
# Note: You can set per-domain or per-user transport in account profile page.
default_mta_transport = 'dovecot'
# Min/Max admin password length. 0 means unlimited.
# - min_passwd_length: at least 1 character is required.
# Normal admin can not set shorter/longer password lengths than global settings
# defined here.
min_passwd_length = 8
max_passwd_length = 0
#####################################################################
# Database used to store iRedAdmin data. e.g. sessions, log.
#
iredadmin_db_host = '127.0.0.1'
iredadmin_db_port = 5432
iredadmin_db_name = 'iredadmin'
iredadmin_db_user = 'iredadmin'
iredadmin_db_password = 'password'
############################################
# Database used to store mail accounts.
#
vmail_db_host = '127.0.0.1'
vmail_db_port = 5432
vmail_db_name = 'vmail'
vmail_db_user = 'vmailadmin'
vmail_db_password = 'password'
##############################################################################
# Settings used for Amavisd-new integration. Provides spam/virus quaranting,
# releasing, etc.
#
# Log basic info of in/out emails into SQL (@storage_sql_dsn): True, False.
# It's @storage_sql_dsn setting in amavisd. You can find this setting
# in amavisd-new config files:
# - On RHEL/CentOS: /etc/amavisd.conf or /etc/amavisd/amavisd.conf
# - On Debian/Ubuntu: /etc/amavis/conf.d/50-user.conf
# - On FreeBSD: /usr/local/etc/amavisd.conf
amavisd_enable_logging = True
amavisd_db_host = '127.0.0.1'
amavisd_db_port = 5432
amavisd_db_name = 'amavisd'
amavisd_db_user = 'amavisd'
amavisd_db_password = 'password'
# #### Quarantining ####
# Release quarantined SPAM/Virus mails: True, False.
# iRedAdmin-Pro will connect to @amavisd_db_host to release quarantined mails.
# How to enable quarantining in Amavisd-new:
# http://www.iredmail.org/docs/quarantining.html
amavisd_enable_quarantine = True
# Port of Amavisd protocol 'AM.PDP-INET'. Default is 9998.
# If Amavisd is not running on database server specified in amavisd_db_host,
# please set the server address in parameter `AMAVISD_QUARANTINE_HOST`.
# Default is '127.0.0.1'. Sample setting:
#AMAVISD_QUARANTINE_HOST = '192.168.1.1'
amavisd_quarantine_port = 9998
# Enable per-recipient spam policy, white/blacklist.
amavisd_enable_policy_lookup = True
##############################################################################
# Settings used for iRedAPD integration. Provides throttling and more.
#
iredapd_enabled = True
iredapd_db_host = '127.0.0.1'
iredapd_db_port = 5432
iredapd_db_name = 'iredapd'
iredapd_db_user = 'iredapd'
iredapd_db_password = 'password'
##############################################################################
# Settings used for mlmmj (mailing list manager) and mlmmjadmin integration.
#
# The API auth token required to access mlmmjadmin API.
mlmmjadmin_api_auth_token = ''
############################################################
# Place your custom settings below, you can override all settings in this file
# and libs/default_settings.py here.
#