Merge branch 'master' of github.com:Diman0/Mailu into fix-sso-1929

master
Dimitri Huisman 3 years ago
commit ed7adf52a6

@ -13,7 +13,7 @@ COPY webpack.config.js ./
COPY assets ./assets COPY assets ./assets
RUN set -eu \ RUN set -eu \
&& sed -i 's/#007bff/#55a5d9/' node_modules/admin-lte/build/scss/_bootstrap-variables.scss \ && sed -i 's/#007bff/#55a5d9/' node_modules/admin-lte/build/scss/_bootstrap-variables.scss \
&& for l in ca da de:de_de en:en-gb es:es_es eu fr:fr_fr he hu is it:it_it ja nb_NO:no_nb nl:nl_nl pl pt:pt_pt ru sv:sv_se zh_CN:zh; do \ && for l in ca da de:de_de en:en-gb es:es_es eu fr:fr_fr he hu is it:it_it ja nb_NO:no_nb nl:nl_nl pl pt:pt_pt ru sv:sv_se zh; do \
cp node_modules/datatables.net-plugins/i18n/${l#*:}.json assets/${l%:*}.json; \ cp node_modules/datatables.net-plugins/i18n/${l#*:}.json assets/${l%:*}.json; \
done \ done \
&& node_modules/.bin/webpack-cli --color && node_modules/.bin/webpack-cli --color

@ -66,5 +66,12 @@ $('document').ready(function() {
// init clipboard.js // init clipboard.js
new ClipboardJS('.btn-clip'); new ClipboardJS('.btn-clip');
// disable login if not possible
var l = $('#login_needs_https');
if (l.length && window.location.protocol != 'https:') {
l.removeClass("d-none");
$('form :input').prop('disabled', true);
}
}); });

@ -28,7 +28,9 @@ def create_app_from_config(config):
utils.proxy.init_app(app) utils.proxy.init_app(app)
utils.migrate.init_app(app, models.db) utils.migrate.init_app(app, models.db)
app.device_cookie_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('DEVICE_COOKIE_KEY', 'utf-8'), 'sha256').digest()
app.temp_token_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('WEBMAIL_TEMP_TOKEN_KEY', 'utf-8'), 'sha256').digest() app.temp_token_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('WEBMAIL_TEMP_TOKEN_KEY', 'utf-8'), 'sha256').digest()
app.srs_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('SRS_KEY', 'utf-8'), 'sha256').digest()
# Initialize list of translations # Initialize list of translations
config.translations = { config.translations = {

@ -2,6 +2,7 @@ import os
from datetime import timedelta from datetime import timedelta
from socrate import system from socrate import system
import ipaddress
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
# Specific to the admin UI # Specific to the admin UI
@ -36,8 +37,12 @@ DEFAULT_CONFIG = {
'TLS_FLAVOR': 'cert', 'TLS_FLAVOR': 'cert',
'INBOUND_TLS_ENFORCE': False, 'INBOUND_TLS_ENFORCE': False,
'DEFER_ON_TLS_ERROR': True, 'DEFER_ON_TLS_ERROR': True,
'AUTH_RATELIMIT': '1000/minute;10000/hour', 'AUTH_RATELIMIT_IP': '60/hour',
'AUTH_RATELIMIT_SUBNET': False, 'AUTH_RATELIMIT_IP_V4_MASK': 24,
'AUTH_RATELIMIT_IP_V6_MASK': 56,
'AUTH_RATELIMIT_USER': '100/day',
'AUTH_RATELIMIT_EXEMPTION': '',
'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400,
'DISABLE_STATISTICS': False, 'DISABLE_STATISTICS': False,
# Mail settings # Mail settings
'DMARC_RUA': None, 'DMARC_RUA': None,
@ -49,6 +54,7 @@ DEFAULT_CONFIG = {
'DKIM_PATH': '/dkim/{domain}.{selector}.key', 'DKIM_PATH': '/dkim/{domain}.{selector}.key',
'DEFAULT_QUOTA': 1000000000, 'DEFAULT_QUOTA': 1000000000,
'MESSAGE_RATELIMIT': '200/day', 'MESSAGE_RATELIMIT': '200/day',
'RECIPIENT_DELIMITER': '',
# Web settings # Web settings
'SITENAME': 'Mailu', 'SITENAME': 'Mailu',
'WEBSITE': 'https://mailu.io', 'WEBSITE': 'https://mailu.io',
@ -148,6 +154,7 @@ class ConfigManager(dict):
self.config['SESSION_COOKIE_HTTPONLY'] = True self.config['SESSION_COOKIE_HTTPONLY'] = True
self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME']))
hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')] hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')]
self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(cidr, False) for cidr in (cidr.strip() for cidr in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) if cidr)
self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAMES'] = ','.join(hostnames)
self.config['HOSTNAME'] = hostnames[0] self.config['HOSTNAME'] = hostnames[0]
# update the app config itself # update the app config itself

@ -5,6 +5,7 @@ import re
import urllib import urllib
import ipaddress import ipaddress
import socket import socket
import sqlalchemy.exc
import tenacity import tenacity
SUPPORTED_AUTH_METHODS = ["none", "plain"] SUPPORTED_AUTH_METHODS = ["none", "plain"]
@ -19,6 +20,11 @@ STATUSES = {
"encryption": ("Must issue a STARTTLS command first", { "encryption": ("Must issue a STARTTLS command first", {
"smtp": "530 5.7.0" "smtp": "530 5.7.0"
}), }),
"ratelimit": ("Temporary authentication failure (rate-limit)", {
"imap": "LIMIT",
"smtp": "451 4.3.2",
"pop3": "-ERR [LOGIN-DELAY] Retry later"
}),
} }
def check_credentials(user, password, ip, protocol=None): def check_credentials(user, password, ip, protocol=None):
@ -71,8 +77,8 @@ def handle_authentication(headers):
} }
# Authenticated user # Authenticated user
elif method == "plain": elif method == "plain":
service_port = int(urllib.parse.unquote(headers["Auth-Port"])) is_valid_user = False
if service_port == 25: if headers["Auth-Port"] == '25':
return { return {
"Auth-Status": "AUTH not supported", "Auth-Status": "AUTH not supported",
"Auth-Error-Code": "502 5.5.1", "Auth-Error-Code": "502 5.5.1",
@ -84,25 +90,37 @@ def handle_authentication(headers):
# we need to manually decode. # we need to manually decode.
raw_user_email = urllib.parse.unquote(headers["Auth-User"]) raw_user_email = urllib.parse.unquote(headers["Auth-User"])
raw_password = urllib.parse.unquote(headers["Auth-Pass"]) raw_password = urllib.parse.unquote(headers["Auth-Pass"])
user_email = 'invalid'
try: try:
user_email = raw_user_email.encode("iso8859-1").decode("utf8") user_email = raw_user_email.encode("iso8859-1").decode("utf8")
password = raw_password.encode("iso8859-1").decode("utf8") password = raw_password.encode("iso8859-1").decode("utf8")
ip = urllib.parse.unquote(headers["Client-Ip"])
except: except:
app.logger.warn(f'Received undecodable user/password from nginx: {raw_user_email!r}/{raw_password!r}') app.logger.warn(f'Received undecodable user/password from nginx: {raw_user_email!r}/{raw_password!r}')
else: else:
user = models.User.query.get(user_email) try:
ip = urllib.parse.unquote(headers["Client-Ip"]) user = models.User.query.get(user_email)
if check_credentials(user, password, ip, protocol): is_valid_user = True
server, port = get_server(headers["Auth-Protocol"], True) except sqlalchemy.exc.StatementError as exc:
return { exc = str(exc).split('\n', 1)[0]
"Auth-Status": "OK", app.logger.warn(f'Invalid user {user_email!r}: {exc}')
"Auth-Server": server, else:
"Auth-Port": port ip = urllib.parse.unquote(headers["Client-Ip"])
} if check_credentials(user, password, ip, protocol):
server, port = get_server(headers["Auth-Protocol"], True)
return {
"Auth-Status": "OK",
"Auth-Server": server,
"Auth-User": user_email,
"Auth-User-Exists": is_valid_user,
"Auth-Port": port
}
status, code = get_status(protocol, "authentication") status, code = get_status(protocol, "authentication")
return { return {
"Auth-Status": status, "Auth-Status": status,
"Auth-Error-Code": code, "Auth-Error-Code": code,
"Auth-User": user_email,
"Auth-User-Exists": is_valid_user,
"Auth-Wait": 0 "Auth-Wait": 0
} }
# Unexpected # Unexpected

@ -1,3 +1,3 @@
__all__ = [ __all__ = [
'auth', 'postfix', 'dovecot', 'fetch' 'auth', 'postfix', 'dovecot', 'fetch', 'rspamd'
] ]

@ -5,19 +5,17 @@ from flask import current_app as app
import flask import flask
import flask_login import flask_login
import base64 import base64
import ipaddress
@internal.route("/auth/email") @internal.route("/auth/email")
def nginx_authentication(): def nginx_authentication():
""" Main authentication endpoint for Nginx email server """ Main authentication endpoint for Nginx email server
""" """
limiter = utils.limiter.get_limiter(app.config["AUTH_RATELIMIT"], "auth-ip")
client_ip = flask.request.headers["Client-Ip"] client_ip = flask.request.headers["Client-Ip"]
if not limiter.test(client_ip): if utils.limiter.should_rate_limit_ip(client_ip):
status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit')
response = flask.Response() response = flask.Response()
response.headers['Auth-Status'] = 'Authentication rate limit from one source exceeded' response.headers['Auth-Status'] = status
response.headers['Auth-Error-Code'] = '451 4.3.2' response.headers['Auth-Error-Code'] = code
if int(flask.request.headers['Auth-Login-Attempt']) < 10: if int(flask.request.headers['Auth-Login-Attempt']) < 10:
response.headers['Auth-Wait'] = '3' response.headers['Auth-Wait'] = '3'
return response return response
@ -25,14 +23,27 @@ def nginx_authentication():
response = flask.Response() response = flask.Response()
for key, value in headers.items(): for key, value in headers.items():
response.headers[key] = str(value) response.headers[key] = str(value)
if ("Auth-Status" not in headers) or (headers["Auth-Status"] != "OK"): is_valid_user = False
limit_subnet = str(app.config["AUTH_RATELIMIT_SUBNET"]) != 'False' if response.headers.get("Auth-User-Exists"):
subnet = ipaddress.ip_network(app.config["SUBNET"]) username = response.headers["Auth-User"]
if limit_subnet or ipaddress.ip_address(client_ip) not in subnet: if utils.limiter.should_rate_limit_user(username, client_ip):
limiter.hit(flask.request.headers["Client-Ip"]) # FIXME could be done before handle_authentication()
status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit')
response = flask.Response()
response.headers['Auth-Status'] = status
response.headers['Auth-Error-Code'] = code
if int(flask.request.headers['Auth-Login-Attempt']) < 10:
response.headers['Auth-Wait'] = '3'
return response
is_valid_user = True
if headers.get("Auth-Status") == "OK":
utils.limiter.exempt_ip_from_ratelimits(client_ip)
elif is_valid_user:
utils.limiter.rate_limit_user(username, client_ip)
else:
rate_limit_ip(client_ip)
return response return response
@internal.route("/auth/admin") @internal.route("/auth/admin")
def admin_authentication(): def admin_authentication():
""" Fails if the user is not an authenticated admin. """ Fails if the user is not an authenticated admin.
@ -60,15 +71,29 @@ def user_authentication():
def basic_authentication(): def basic_authentication():
""" Tries to authenticate using the Authorization header. """ Tries to authenticate using the Authorization header.
""" """
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
if utils.limiter.should_rate_limit_ip(client_ip):
response = flask.Response(status=401)
response.headers["WWW-Authenticate"] = 'Basic realm="Authentication rate limit from one source exceeded"'
response.headers['Retry-After'] = '60'
return response
authorization = flask.request.headers.get("Authorization") authorization = flask.request.headers.get("Authorization")
if authorization and authorization.startswith("Basic "): if authorization and authorization.startswith("Basic "):
encoded = authorization.replace("Basic ", "") encoded = authorization.replace("Basic ", "")
user_email, password = base64.b64decode(encoded).split(b":", 1) user_email, password = base64.b64decode(encoded).split(b":", 1)
user = models.User.query.get(user_email.decode("utf8")) user_email = user_email.decode("utf8")
if nginx.check_credentials(user, password.decode('utf-8'), flask.request.remote_addr, "web"): if utils.limiter.should_rate_limit_user(user_email, client_ip):
response = flask.Response(status=401)
response.headers["WWW-Authenticate"] = 'Basic realm="Authentication rate limit for this username exceeded"'
response.headers['Retry-After'] = '60'
return response
user = models.User.query.get(user_email)
if user and nginx.check_credentials(user, password.decode('utf-8'), client_ip, "web"):
response = flask.Response() response = flask.Response()
response.headers["X-User"] = models.IdnaEmail.process_bind_param(flask_login, user.email, "") response.headers["X-User"] = models.IdnaEmail.process_bind_param(flask_login, user.email, "")
utils.limiter.exempt_ip_from_ratelimits(client_ip)
return response return response
utils.limiter.rate_limit_user(user_email, client_ip) if user else utils.limiter.rate_limit_ip(client_ip)
response = flask.Response(status=401) response = flask.Response(status=401)
response.headers["WWW-Authenticate"] = 'Basic realm="Login Required"' response.headers["WWW-Authenticate"] = 'Basic realm="Login Required"'
return response return response

@ -108,7 +108,7 @@ def postfix_recipient_map(recipient):
This is meant for bounces to go back to the original sender. This is meant for bounces to go back to the original sender.
""" """
srs = srslib.SRS(flask.current_app.config["SECRET_KEY"]) srs = srslib.SRS(flask.current_app.srs_key)
if srslib.SRS.is_srs_address(recipient): if srslib.SRS.is_srs_address(recipient):
try: try:
return flask.jsonify(srs.reverse(recipient)) return flask.jsonify(srs.reverse(recipient))
@ -123,7 +123,7 @@ def postfix_sender_map(sender):
This is for bounces to come back the reverse path properly. This is for bounces to come back the reverse path properly.
""" """
srs = srslib.SRS(flask.current_app.config["SECRET_KEY"]) srs = srslib.SRS(flask.current_app.srs_key)
domain = flask.current_app.config["DOMAIN"] domain = flask.current_app.config["DOMAIN"]
try: try:
localpart, domain_name = models.Email.resolve_domain(sender) localpart, domain_name = models.Email.resolve_domain(sender)
@ -140,6 +140,7 @@ def postfix_sender_login(sender):
localpart, domain_name = models.Email.resolve_domain(sender) localpart, domain_name = models.Email.resolve_domain(sender)
if localpart is None: if localpart is None:
return flask.jsonify(",".join(wildcard_senders)) if wildcard_senders else flask.abort(404) return flask.jsonify(",".join(wildcard_senders)) if wildcard_senders else flask.abort(404)
localpart = localpart[:next((i for i, ch in enumerate(localpart) if ch in flask.current_app.config.get('RECIPIENT_DELIMITER')), None)]
destination = models.Email.resolve_destination(localpart, domain_name, True) destination = models.Email.resolve_destination(localpart, domain_name, True)
destination = [*destination, *wildcard_senders] if destination else [*wildcard_senders] destination = [*destination, *wildcard_senders] if destination else [*wildcard_senders]
return flask.jsonify(",".join(destination)) if destination else flask.abort(404) return flask.jsonify(",".join(destination)) if destination else flask.abort(404)

@ -0,0 +1,30 @@
from mailu import models
from mailu.internal import internal
import flask
def vault_error(*messages, status=404):
return flask.make_response(flask.jsonify({'errors':messages}), status)
# rspamd key format:
# {"selectors":[{"pubkey":"...","domain":"...","valid_start":TS,"valid_end":TS,"key":"...","selector":"...","bits":...,"alg":"..."}]}
# hashicorp vault answer format:
# {"request_id":"...","lease_id":"","renewable":false,"lease_duration":2764800,"data":{...see above...},"wrap_info":null,"warnings":null,"auth":null}
@internal.route("/rspamd/vault/v1/dkim/<domain_name>", methods=['GET'])
def rspamd_dkim_key(domain_name):
domain = models.Domain.query.get(domain_name) or flask.abort(vault_error('unknown domain'))
key = domain.dkim_key or flask.abort(vault_error('no dkim key', status=400))
return flask.jsonify({
'data': {
'selectors': [
{
'domain' : domain.name,
'key' : key.decode('utf8'),
'selector': flask.current_app.config.get('DKIM_SELECTOR', 'dkim'),
}
]
}
})

@ -1,7 +1,12 @@
from mailu import utils
from flask import current_app as app
import base64
import limits import limits
import limits.storage import limits.storage
import limits.strategies import limits.strategies
import hmac
import secrets
class LimitWrapper(object): class LimitWrapper(object):
""" Wraps a limit by providing the storage, item and identifiers """ Wraps a limit by providing the storage, item and identifiers
@ -32,3 +37,58 @@ class LimitWraperFactory(object):
def get_limiter(self, limit, *args): def get_limiter(self, limit, *args):
return LimitWrapper(self.limiter, limits.parse(limit), *args) return LimitWrapper(self.limiter, limits.parse(limit), *args)
def is_subject_to_rate_limits(self, ip):
return False if utils.is_exempt_from_ratelimits(ip) else not (self.storage.get(f'exempt-{ip}') > 0)
def exempt_ip_from_ratelimits(self, ip):
self.storage.incr(f'exempt-{ip}', app.config["AUTH_RATELIMIT_EXEMPTION_LENGTH"], True)
def should_rate_limit_ip(self, ip):
limiter = self.get_limiter(app.config["AUTH_RATELIMIT_IP"], 'auth-ip')
client_network = utils.extract_network_from_ip(ip)
is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(client_network)
if is_rate_limited:
app.logger.warn(f'Authentication attempt from {ip} has been rate-limited.')
return is_rate_limited
def rate_limit_ip(self, ip):
if ip != app.config['WEBMAIL_ADDRESS']:
limiter = self.get_limiter(app.config["AUTH_RATELIMIT_IP"], 'auth-ip')
client_network = utils.extract_network_from_ip(ip)
if self.is_subject_to_rate_limits(ip):
limiter.hit(client_network)
def should_rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None):
limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user')
is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(device_cookie if device_cookie_name == username else username)
if is_rate_limited:
app.logger.warn(f'Authentication attempt from {ip} for {username} has been rate-limited.')
return is_rate_limited
def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None):
limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user')
if self.is_subject_to_rate_limits(ip):
limiter.hit(device_cookie if device_cookie_name == username else username)
""" Device cookies as described on:
https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies
"""
def parse_device_cookie(self, cookie):
try:
login, nonce, _ = cookie.split('$')
if hmac.compare_digest(cookie, self.device_cookie(login, nonce)):
return nonce, login
except:
pass
return None, None
""" Device cookies don't require strong crypto:
72bits of nonce, 96bits of signature is more than enough
and these values avoid padding in most cases
"""
def device_cookie(self, username, nonce=None):
if not nonce:
nonce = secrets.token_urlsafe(9)
sig = str(base64.urlsafe_b64encode(hmac.new(app.device_cookie_key, bytearray(f'device_cookie|{username}|{nonce}', 'utf-8'), 'sha256').digest()[20:]), 'utf-8')
return f'{username}${nonce}${sig}'

@ -57,6 +57,8 @@ class IdnaEmail(db.TypeDecorator):
def process_bind_param(self, value, dialect): def process_bind_param(self, value, dialect):
""" encode unicode domain part of email address to punycode """ """ encode unicode domain part of email address to punycode """
if not '@' in value:
raise ValueError('invalid email address (no "@")')
localpart, domain_name = value.lower().rsplit('@', 1) localpart, domain_name = value.lower().rsplit('@', 1)
if '@' in localpart: if '@' in localpart:
raise ValueError('email local part must not contain "@"') raise ValueError('email local part must not contain "@"')
@ -241,6 +243,13 @@ class Domain(Base):
ruf = f' ruf=mailto:{ruf}@{domain};' if ruf else '' ruf = f' ruf=mailto:{ruf}@{domain};' if ruf else ''
return f'_dmarc.{self.name}. 600 IN TXT "v=DMARC1; p=reject;{rua}{ruf} adkim=s; aspf=s"' return f'_dmarc.{self.name}. 600 IN TXT "v=DMARC1; p=reject;{rua}{ruf} adkim=s; aspf=s"'
@cached_property
def dns_dmarc_report(self):
""" return DMARC report record for mailu server """
if self.dkim_key:
domain = app.config['DOMAIN']
return f'{self.name}._report._dmarc.{domain}. 600 IN TXT "v=DMARC1"'
@cached_property @cached_property
def dns_autoconfig(self): def dns_autoconfig(self):
""" return list of auto configuration records (RFC6186) """ """ return list of auto configuration records (RFC6186) """
@ -560,6 +569,8 @@ class User(Base, Email):
""" verifies password against stored hash """ verifies password against stored hash
and updates hash if outdated and updates hash if outdated
""" """
if password == '':
return False
cache_result = self._credential_cache.get(self.get_id()) cache_result = self._credential_cache.get(self.get_id())
current_salt = self.password.split('$')[3] if len(self.password.split('$')) == 5 else None current_salt = self.password.split('$')[3] if len(self.password.split('$')) == 5 else None
if cache_result and current_salt: if cache_result and current_salt:

@ -3,9 +3,11 @@ msgstr ""
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: POEditor.com\n" "X-Generator: Poedit 1.5.7\n"
"Project-Id-Version: Mailu\n" "Project-Id-Version: Mailu\n"
"Language: zh-CN\n" "Language: zh\n"
"Last-Translator: Chris Chuan <Chris.chuan@gmail.com>\n"
"Language-Team: \n"
#: mailu/ui/forms.py:32 #: mailu/ui/forms.py:32
msgid "Invalid email address." msgid "Invalid email address."
@ -28,7 +30,7 @@ msgstr "密码"
#: mailu/ui/forms.py:42 mailu/ui/templates/login.html:4 #: mailu/ui/forms.py:42 mailu/ui/templates/login.html:4
#: mailu/ui/templates/sidebar.html:111 #: mailu/ui/templates/sidebar.html:111
msgid "Sign in" msgid "Sign in"
msgstr "注册" msgstr "登录"
#: mailu/ui/forms.py:46 mailu/ui/forms.py:56 #: mailu/ui/forms.py:46 mailu/ui/forms.py:56
#: mailu/ui/templates/domain/details.html:27 #: mailu/ui/templates/domain/details.html:27
@ -44,6 +46,14 @@ msgstr "最大用户数"
msgid "Maximum alias count" msgid "Maximum alias count"
msgstr "最大别名数" msgstr "最大别名数"
#: mailu/ui/forms.py:49
msgid "Maximum user quota"
msgstr "最大用户配额"
#: mailu/ui/forms.py:50
msgid "Enable sign-up"
msgstr "启用注册"
#: mailu/ui/forms.py:51 mailu/ui/forms.py:72 mailu/ui/forms.py:83 #: mailu/ui/forms.py:51 mailu/ui/forms.py:72 mailu/ui/forms.py:83
#: mailu/ui/forms.py:128 mailu/ui/forms.py:140 #: mailu/ui/forms.py:128 mailu/ui/forms.py:140
#: mailu/ui/templates/alias/list.html:21 mailu/ui/templates/domain/list.html:21 #: mailu/ui/templates/alias/list.html:21 mailu/ui/templates/domain/list.html:21
@ -57,10 +67,30 @@ msgstr "说明"
msgid "Create" msgid "Create"
msgstr "创建" msgstr "创建"
#: mailu/ui/forms.py:57
msgid "Initial admin"
msgstr "初始管理员"
#: mailu/ui/forms.py:58
msgid "Admin password"
msgstr "管理员密码"
#: mailu/ui/forms.py:59 mailu/ui/forms.py:79 mailu/ui/forms.py:91 #: mailu/ui/forms.py:59 mailu/ui/forms.py:79 mailu/ui/forms.py:91
msgid "Confirm password" msgid "Confirm password"
msgstr "确认密码" msgstr "确认密码"
#: mailu/ui/forms.py:65
msgid "Alternative name"
msgstr "备用名称"
#: mailu/ui/forms.py:70
msgid "Relayed domain name"
msgstr "中继域域名"
#: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18
msgid "Remote host"
msgstr "远程主机"
#: mailu/ui/forms.py:80 mailu/ui/templates/user/list.html:22 #: mailu/ui/forms.py:80 mailu/ui/templates/user/list.html:22
#: mailu/ui/templates/user/signup_domain.html:16 #: mailu/ui/templates/user/signup_domain.html:16
msgid "Quota" msgid "Quota"
@ -74,10 +104,24 @@ msgstr "允许IMAP访问"
msgid "Allow POP3 access" msgid "Allow POP3 access"
msgstr "允许POP3访问" msgstr "允许POP3访问"
#: mailu/ui/forms.py:84
msgid "Enabled"
msgstr "启用"
#: mailu/ui/forms.py:85 #: mailu/ui/forms.py:85
msgid "Save" msgid "Save"
msgstr "保存" msgstr "保存"
#: mailu/ui/forms.py:89
msgid "Email address"
msgstr "邮件地址"
#: mailu/ui/forms.py:93 mailu/ui/templates/sidebar.html:117
#: mailu/ui/templates/user/signup.html:4
#: mailu/ui/templates/user/signup_domain.html:4
msgid "Sign up"
msgstr "注册"
#: mailu/ui/forms.py:97 #: mailu/ui/forms.py:97
msgid "Displayed name" msgid "Displayed name"
msgstr "显示名称" msgstr "显示名称"
@ -86,10 +130,23 @@ msgstr "显示名称"
msgid "Enable spam filter" msgid "Enable spam filter"
msgstr "启用垃圾邮件过滤" msgstr "启用垃圾邮件过滤"
#: mailu/ui/forms.py:80 #: mailu/ui/forms.py:99
msgid "Spam filter threshold" msgid "Spam filter tolerance"
msgstr "垃圾邮件过滤器阈值" msgstr "垃圾邮件过滤器阈值"
#: mailu/ui/forms.py:100
msgid "Enable forwarding"
msgstr "启用转发"
#: mailu/ui/forms.py:101
msgid "Keep a copy of the emails"
msgstr "保留电子邮件副本"
#: mailu/ui/forms.py:103 mailu/ui/forms.py:139
#: mailu/ui/templates/alias/list.html:20
msgid "Destination"
msgstr "目的地址"
#: mailu/ui/forms.py:105 #: mailu/ui/forms.py:105
msgid "Save settings" msgid "Save settings"
msgstr "保存设置" msgstr "保存设置"
@ -102,19 +159,6 @@ msgstr "检查密码"
msgid "Update password" msgid "Update password"
msgstr "更新密码" msgstr "更新密码"
#: mailu/ui/forms.py:100
msgid "Enable forwarding"
msgstr "启用转发"
#: mailu/ui/forms.py:103 mailu/ui/forms.py:139
#: mailu/ui/templates/alias/list.html:20
msgid "Destination"
msgstr "目的地址"
#: mailu/ui/forms.py:120
msgid "Update"
msgstr "更新"
#: mailu/ui/forms.py:115 #: mailu/ui/forms.py:115
msgid "Enable automatic reply" msgid "Enable automatic reply"
msgstr "启用自动回复" msgstr "启用自动回复"
@ -127,6 +171,22 @@ msgstr "回复主题"
msgid "Reply body" msgid "Reply body"
msgstr "回复正文" msgstr "回复正文"
#: mailu/ui/forms.py:119
msgid "End of vacation"
msgstr "假期结束"
#: mailu/ui/forms.py:120
msgid "Update"
msgstr "更新"
#: mailu/ui/forms.py:125
msgid "Your token (write it down, as it will never be displayed again)"
msgstr "您的令牌(请记录,它只显示这一次)"
#: mailu/ui/forms.py:130 mailu/ui/templates/token/list.html:20
msgid "Authorized IP"
msgstr "授权IP"
#: mailu/ui/forms.py:136 #: mailu/ui/forms.py:136
msgid "Alias" msgid "Alias"
msgstr "别名" msgstr "别名"
@ -169,11 +229,44 @@ msgstr "启用TLS"
msgid "Username" msgid "Username"
msgstr "用户名" msgstr "用户名"
#: mailu/ui/forms.py:163
msgid "Keep emails on the server"
msgstr "在服务器上保留电子邮件"
#: mailu/ui/forms.py:168
msgid "Announcement subject"
msgstr "公告主题"
#: mailu/ui/forms.py:170
msgid "Announcement body"
msgstr "公告正文"
#: mailu/ui/forms.py:172
msgid "Send"
msgstr "发送"
#: mailu/ui/templates/announcement.html:4
msgid "Public announcement"
msgstr "公开公告"
#: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:82
msgid "Client setup"
msgstr "客户端设置"
#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43
msgid "Mail protocol"
msgstr "邮件协议"
#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51
msgid "Server name"
msgstr "服务器名称"
#: mailu/ui/templates/confirm.html:4 #: mailu/ui/templates/confirm.html:4
msgid "Confirm action" msgid "Confirm action"
msgstr "确认操作" msgstr "确认操作"
#: mailu/ui/templates/confirm.html:13 #: mailu/ui/templates/confirm.html:13
#, python-format
msgid "You are about to %(action)s. Please confirm your action." msgid "You are about to %(action)s. Please confirm your action."
msgstr "即将%(action)s请确认您的操作。" msgstr "即将%(action)s请确认您的操作。"
@ -185,54 +278,18 @@ msgstr "Docker错误"
msgid "An error occurred while talking to the Docker server." msgid "An error occurred while talking to the Docker server."
msgstr "Docker服务器通信出错" msgstr "Docker服务器通信出错"
#: mailu/admin/templates/login.html:6
msgid "Your account"
msgstr "你的帐户"
#: mailu/ui/templates/login.html:8 #: mailu/ui/templates/login.html:8
msgid "to access the administration tools" msgid "to access the administration tools"
msgstr "访问管理员工具" msgstr "访问管理工具"
#: mailu/ui/templates/services.html:4 mailu/ui/templates/sidebar.html:39
msgid "Services status"
msgstr "服务状态"
#: mailu/ui/templates/services.html:10
msgid "Service"
msgstr "服务"
#: mailu/ui/templates/fetch/list.html:23 mailu/ui/templates/services.html:11
msgid "Status"
msgstr "状态"
#: mailu/ui/templates/services.html:12
msgid "PID"
msgstr "进程ID"
#: mailu/ui/templates/services.html:13
msgid "Image"
msgstr "镜像"
#: mailu/ui/templates/services.html:14
msgid "Started"
msgstr "已开始"
#: mailu/ui/templates/services.html:15
msgid "Last update"
msgstr "最后更新"
#: mailu/ui/templates/sidebar.html:8 #: mailu/ui/templates/sidebar.html:8
msgid "My account" msgid "My account"
msgstr "我的户" msgstr "我的账户"
#: mailu/ui/templates/sidebar.html:11 mailu/ui/templates/user/list.html:34 #: mailu/ui/templates/sidebar.html:11 mailu/ui/templates/user/list.html:34
msgid "Settings" msgid "Settings"
msgstr "设置" msgstr "设置"
#: mailu/ui/templates/user/settings.html:22
msgid "Auto-forward"
msgstr "自动转发"
#: mailu/ui/templates/sidebar.html:21 mailu/ui/templates/user/list.html:35 #: mailu/ui/templates/sidebar.html:21 mailu/ui/templates/user/list.html:35
msgid "Auto-reply" msgid "Auto-reply"
msgstr "自动回复" msgstr "自动回复"
@ -240,39 +297,71 @@ msgstr "自动回复"
#: mailu/ui/templates/fetch/list.html:4 mailu/ui/templates/sidebar.html:26 #: mailu/ui/templates/fetch/list.html:4 mailu/ui/templates/sidebar.html:26
#: mailu/ui/templates/user/list.html:36 #: mailu/ui/templates/user/list.html:36
msgid "Fetched accounts" msgid "Fetched accounts"
msgstr "代收户" msgstr "代收户"
#: mailu/ui/templates/sidebar.html:105 #: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4
msgid "Sign out" msgid "Authentication tokens"
msgstr "登出" msgstr "认证令牌"
#: mailu/ui/templates/sidebar.html:35 #: mailu/ui/templates/sidebar.html:35
msgid "Administration" msgid "Administration"
msgstr "管理" msgstr "管理"
#: mailu/ui/templates/sidebar.html:44
msgid "Announcement"
msgstr "公告"
#: mailu/ui/templates/sidebar.html:49 #: mailu/ui/templates/sidebar.html:49
msgid "Administrators" msgid "Administrators"
msgstr "管理员" msgstr "管理员"
#: mailu/ui/templates/sidebar.html:54
msgid "Relayed domains"
msgstr "中继域"
#: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15
msgid "Antispam"
msgstr "反垃圾邮件"
#: mailu/ui/templates/sidebar.html:66 #: mailu/ui/templates/sidebar.html:66
msgid "Mail domains" msgid "Mail domains"
msgstr "邮件域" msgstr "邮件域"
#: mailu/ui/templates/sidebar.html:72
msgid "Go to"
msgstr "转到"
#: mailu/ui/templates/sidebar.html:76
msgid "Webmail"
msgstr "网页邮箱"
#: mailu/ui/templates/sidebar.html:87
msgid "Website"
msgstr "网站"
#: mailu/ui/templates/sidebar.html:92 #: mailu/ui/templates/sidebar.html:92
msgid "Help" msgid "Help"
msgstr "帮助" msgstr "帮助"
#: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:98
msgid "Register a domain"
msgstr "注册域名"
#: mailu/ui/templates/sidebar.html:105
msgid "Sign out"
msgstr "登出"
#: mailu/ui/templates/working.html:4 #: mailu/ui/templates/working.html:4
msgid "We are still working on this feature!" msgid "We are still working on this feature!"
msgstr "该功能开发中……" msgstr "该功能开发中……"
#: mailu/ui/templates/admin/create.html:4 #: mailu/ui/templates/admin/create.html:4
msgid "Add a global administrator" msgid "Add a global administrator"
msgstr "添加超级管理员" msgstr "添加全局管理员"
#: mailu/ui/templates/admin/list.html:4 #: mailu/ui/templates/admin/list.html:4
msgid "Global administrators" msgid "Global administrators"
msgstr "超级管理员" msgstr "全局管理员"
#: mailu/ui/templates/admin/list.html:9 #: mailu/ui/templates/admin/list.html:9
msgid "Add administrator" msgid "Add administrator"
@ -323,7 +412,7 @@ msgstr "添加别名"
#: mailu/ui/templates/relay/list.html:20 mailu/ui/templates/token/list.html:21 #: mailu/ui/templates/relay/list.html:20 mailu/ui/templates/token/list.html:21
#: mailu/ui/templates/user/list.html:24 #: mailu/ui/templates/user/list.html:24
msgid "Created" msgid "Created"
msgstr "创建" msgstr "创建"
#: mailu/ui/templates/alias/list.html:23 mailu/ui/templates/domain/list.html:23 #: mailu/ui/templates/alias/list.html:23 mailu/ui/templates/domain/list.html:23
#: mailu/ui/templates/fetch/list.html:25 mailu/ui/templates/relay/list.html:21 #: mailu/ui/templates/fetch/list.html:25 mailu/ui/templates/relay/list.html:21
@ -337,6 +426,22 @@ msgstr "上次编辑"
msgid "Edit" msgid "Edit"
msgstr "编辑" msgstr "编辑"
#: mailu/ui/templates/alternative/create.html:4
msgid "Create alternative domain"
msgstr "创建替代域"
#: mailu/ui/templates/alternative/list.html:4
msgid "Alternative domain list"
msgstr "替代域名列表"
#: mailu/ui/templates/alternative/list.html:12
msgid "Add alternative"
msgstr "添加替代"
#: mailu/ui/templates/alternative/list.html:19
msgid "Name"
msgstr "名称"
#: mailu/ui/templates/domain/create.html:4 #: mailu/ui/templates/domain/create.html:4
#: mailu/ui/templates/domain/list.html:9 #: mailu/ui/templates/domain/list.html:9
msgid "New domain" msgid "New domain"
@ -344,11 +449,15 @@ msgstr "新域"
#: mailu/ui/templates/domain/details.html:4 #: mailu/ui/templates/domain/details.html:4
msgid "Domain details" msgid "Domain details"
msgstr "域详" msgstr "域详细信息"
#: mailu/ui/templates/domain/details.html:15 #: mailu/ui/templates/domain/details.html:15
msgid "Regenerate keys" msgid "Regenerate keys"
msgstr "重新生成密钥" msgstr "重新生成秘钥"
#: mailu/ui/templates/domain/details.html:17
msgid "Generate keys"
msgstr "生成秘钥"
#: mailu/ui/templates/domain/details.html:31 #: mailu/ui/templates/domain/details.html:31
msgid "DNS MX entry" msgid "DNS MX entry"
@ -392,7 +501,7 @@ msgstr "别名数量"
#: mailu/ui/templates/domain/list.html:28 #: mailu/ui/templates/domain/list.html:28
msgid "Details" msgid "Details"
msgstr "详" msgstr "详细信息"
#: mailu/ui/templates/domain/list.html:35 #: mailu/ui/templates/domain/list.html:35
msgid "Users" msgid "Users"
@ -406,26 +515,60 @@ msgstr "别名"
msgid "Managers" msgid "Managers"
msgstr "管理员" msgstr "管理员"
#: mailu/ui/templates/domain/list.html:39
msgid "Alternatives"
msgstr "备选方案"
#: mailu/ui/templates/domain/signup.html:13
msgid ""
"In order to register a new domain, you must first setup the\n"
" domain zone so that the domain <code>MX</code> points to this server"
msgstr "在注册一个新的域名前,您必须先为该域名设置 <code>MX</code> 记录,并使其指向本服务器"
#: mailu/ui/templates/domain/signup.html:18
msgid ""
"If you do not know how to setup an <code>MX</code> record for your DNS "
"zone,\n"
" please contact your DNS provider or administrator. Also, please wait "
"a\n"
" couple minutes after the <code>MX</code> is set so the local server "
"cache\n"
" expires."
msgstr "如果您不知道如何为域名设置 <code>MX</code> 记录请联系你的DNS提供商或者系统管理员。在设置完成 <code>MX</code> 记录后,请等待本地域名服务器的缓存过期。"
#: mailu/ui/templates/fetch/create.html:4 #: mailu/ui/templates/fetch/create.html:4
msgid "Add a fetched account" msgid "Add a fetched account"
msgstr "添加一个代收帐户" msgstr "添加一个代收户"
#: mailu/ui/templates/fetch/edit.html:4 #: mailu/ui/templates/fetch/edit.html:4
msgid "Update a fetched account" msgid "Update a fetched account"
msgstr "更新代收帐户" msgstr "更新代收户"
#: mailu/ui/templates/fetch/list.html:12 #: mailu/ui/templates/fetch/list.html:12
msgid "Add an account" msgid "Add an account"
msgstr "添加一个帐户" msgstr "添加一个户"
#: mailu/ui/templates/fetch/list.html:19 #: mailu/ui/templates/fetch/list.html:19
msgid "Endpoint" msgid "Endpoint"
msgstr "端点" msgstr "端点"
#: mailu/ui/templates/fetch/list.html:21
msgid "Keep emails"
msgstr "保留电子邮件"
#: mailu/ui/templates/fetch/list.html:22 #: mailu/ui/templates/fetch/list.html:22
msgid "Last check" msgid "Last check"
msgstr "上次检查" msgstr "上次检查"
#: mailu/ui/templates/fetch/list.html:35
msgid "yes"
msgstr "是"
#: mailu/ui/templates/fetch/list.html:35
msgid "no"
msgstr "否"
#: mailu/ui/templates/manager/create.html:4 #: mailu/ui/templates/manager/create.html:4
msgid "Add a manager" msgid "Add a manager"
msgstr "添加一个管理员" msgstr "添加一个管理员"
@ -438,41 +581,49 @@ msgstr "管理员列表"
msgid "Add manager" msgid "Add manager"
msgstr "添加管理员" msgstr "添加管理员"
#: mailu/ui/forms.py:168 #: mailu/ui/templates/relay/create.html:4
msgid "Announcement subject" msgid "New relay domain"
msgstr "公告主题" msgstr "新的中继域"
#: mailu/ui/forms.py:170 #: mailu/ui/templates/relay/edit.html:4
msgid "Announcement body" msgid "Edit relayd domain"
msgstr "公告正文" msgstr "编辑中继域"
#: mailu/ui/forms.py:172 #: mailu/ui/templates/relay/list.html:4
msgid "Send" msgid "Relayed domain list"
msgstr "发送" msgstr "中继域列表"
#: mailu/ui/templates/announcement.html:4 #: mailu/ui/templates/relay/list.html:9
msgid "Public announcement" msgid "New relayed domain"
msgstr "公告" msgstr "新的中继域"
#: mailu/ui/templates/announcement.html:8 #: mailu/ui/templates/token/create.html:4
msgid "from" msgid "Create an authentication token"
msgstr "来自" msgstr "创建一个认证令牌"
#: mailu/ui/templates/sidebar.html:44 #: mailu/ui/templates/token/list.html:12
msgid "Announcement" msgid "New token"
msgstr "公告" msgstr "新令牌"
#: mailu/ui/templates/user/create.html:4 #: mailu/ui/templates/user/create.html:4
msgid "New user" msgid "New user"
msgstr "新用户" msgstr "新用户"
#: mailu/ui/templates/user/create.html:15
msgid "General"
msgstr "通用"
#: mailu/ui/templates/user/create.html:22
msgid "Features and quotas"
msgstr "功能和配额"
#: mailu/ui/templates/user/edit.html:4 #: mailu/ui/templates/user/edit.html:4
msgid "Edit user" msgid "Edit user"
msgstr "编辑用户" msgstr "编辑用户"
#: mailu/ui/templates/user/forward.html:4 #: mailu/ui/templates/user/forward.html:4
msgid "Forward emails" msgid "Forward emails"
msgstr "转发电子邮件" msgstr "转发邮件"
#: mailu/ui/templates/user/list.html:4 #: mailu/ui/templates/user/list.html:4
msgid "User list" msgid "User list"
@ -492,201 +643,15 @@ msgstr "功能"
#: mailu/ui/templates/user/password.html:4 #: mailu/ui/templates/user/password.html:4
msgid "Password update" msgid "Password update"
msgstr "密码更新" msgstr "更新密码"
#: mailu/ui/templates/user/reply.html:4 #: mailu/ui/templates/user/reply.html:4
msgid "Automatic reply" msgid "Automatic reply"
msgstr "自动回复" msgstr "自动回复"
#: mailu/ui/forms.py:49 #: mailu/ui/templates/user/settings.html:22
msgid "Maximum user quota" msgid "Auto-forward"
msgstr "最大用户容量" msgstr "自动转发"
#: mailu/ui/forms.py:101
msgid "Keep a copy of the emails"
msgstr "保留电子邮件副本"
#: mailu/ui/forms.py:163
msgid "Keep emails on the server"
msgstr "保留电子邮件在服务器上"
#: mailu/ui/templates/fetch/list.html:21
msgid "Keep emails"
msgstr "保存电子邮件"
#: mailu/ui/templates/fetch/list.html:35
msgid "yes"
msgstr "是"
#: mailu/ui/templates/fetch/list.html:35
msgid "no"
msgstr "否"
#: mailu/ui/forms.py:65
msgid "Alternative name"
msgstr "替代名称"
#: mailu/ui/forms.py:70
msgid "Relayed domain name"
msgstr "中继域域名"
#: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18
msgid "Remote host"
msgstr "远程主机"
#: mailu/ui/templates/sidebar.html:54
msgid "Relayed domains"
msgstr "中继域"
#: mailu/ui/templates/alternative/create.html:4
msgid "Create alternative domain"
msgstr "创建替代域"
#: mailu/ui/templates/alternative/list.html:4
msgid "Alternative domain list"
msgstr "替代域名列表"
#: mailu/ui/templates/alternative/list.html:12
msgid "Add alternative"
msgstr "添加替代"
#: mailu/ui/templates/alternative/list.html:19
msgid "Name"
msgstr "名称"
#: mailu/ui/templates/domain/list.html:39
msgid "Alternatives"
msgstr "备择方案"
#: mailu/ui/templates/relay/create.html:4
msgid "New relay domain"
msgstr "新的中继域"
#: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayd domain"
msgstr "编辑中继域"
#: mailu/ui/templates/relay/list.html:4
msgid "Relayed domain list"
msgstr "中继域列表"
#: mailu/ui/templates/relay/list.html:9
msgid "New relayed domain"
msgstr "新的中继域"
#: mailu/ui/forms.py:125
msgid "Your token (write it down, as it will never be displayed again)"
msgstr "您的令牌(请记录,它只显示这一次)"
#: mailu/ui/forms.py:130 mailu/ui/templates/token/list.html:20
msgid "Authorized IP"
msgstr "授权IP"
#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4
msgid "Authentication tokens"
msgstr "认证令牌"
#: mailu/ui/templates/sidebar.html:72
msgid "Go to"
msgstr "转到"
#: mailu/ui/templates/sidebar.html:76
msgid "Webmail"
msgstr "网页邮箱"
#: mailu/ui/templates/sidebar.html:87
msgid "Website"
msgstr "网站"
#: mailu/ui/templates/token/create.html:4
msgid "Create an authentication token"
msgstr "创建一个认证令牌"
#: mailu/ui/templates/token/list.html:12
msgid "New token"
msgstr "新的令牌"
#: mailu/ui/templates/user/create.html:15
msgid "General"
msgstr "通用"
#: mailu/ui/templates/user/create.html:22
msgid "Features and quotas"
msgstr "功能和配额"
#: mailu/ui/templates/user/settings.html:14
msgid "General settings"
msgstr "常规设置"
#: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15
msgid "Antispam"
msgstr "反垃圾邮件"
#: mailu/ui/forms.py:99
msgid "Spam filter tolerance"
msgstr "垃圾邮件过滤器容忍度"
#: mailu/ui/forms.py:50
msgid "Enable sign-up"
msgstr "启用用户注册"
#: mailu/ui/forms.py:57
msgid "Initial admin"
msgstr "初始管理员"
#: mailu/ui/forms.py:58
msgid "Admin password"
msgstr "管理员密码"
#: mailu/ui/forms.py:84
msgid "Enabled"
msgstr "启用"
#: mailu/ui/forms.py:89
msgid "Email address"
msgstr "邮件地址"
#: mailu/ui/forms.py:93 mailu/ui/templates/sidebar.html:117
#: mailu/ui/templates/user/signup.html:4
#: mailu/ui/templates/user/signup_domain.html:4
msgid "Sign up"
msgstr "注册"
#: mailu/ui/forms.py:119
msgid "End of vacation"
msgstr "假期结束"
#: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:82
msgid "Client setup"
msgstr "客户端设置"
#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43
msgid "Mail protocol"
msgstr "邮件协议"
#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51
msgid "Server name"
msgstr "服务器名"
#: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:98
msgid "Register a domain"
msgstr "注册域名"
#: mailu/ui/templates/domain/details.html:17
msgid "Generate keys"
msgstr "生成密钥"
#: mailu/ui/templates/domain/signup.html:13
msgid "In order to register a new domain, you must first setup the\n"
" domain zone so that the domain <code>MX</code> points to this server"
msgstr "在注册一个新的域名前,您必须先为该域名设置 <code>MX</code> 记录,并使其指向本服务器"
#: mailu/ui/templates/domain/signup.html:18
msgid "If you do not know how to setup an <code>MX</code> record for your DNS zone,\n"
" please contact your DNS provider or administrator. Also, please wait a\n"
" couple minutes after the <code>MX</code> is set so the local server cache\n"
" expires."
msgstr "如果您不知道如何为域名设置 <code>MX</code> 记录请联系你的DNS提供商或者系统管理员。在设置完成 <code>MX</code> 记录后,请等待本地域名服务器的缓存过期。"
#: mailu/ui/templates/user/signup_domain.html:8 #: mailu/ui/templates/user/signup_domain.html:8
msgid "pick a domain for the new account" msgid "pick a domain for the new account"
@ -700,3 +665,14 @@ msgstr "域名"
msgid "Available slots" msgid "Available slots"
msgstr "可用" msgstr "可用"
#~ msgid "Your account"
#~ msgstr ""
#~ msgid "Spam filter threshold"
#~ msgstr ""
#~ msgid "from"
#~ msgstr ""
#~ msgid "General settings"
#~ msgstr ""

@ -46,7 +46,10 @@
</tr> </tr>
<tr> <tr>
<th>{% trans %}DNS DMARC entry{% endtrans %}</th> <th>{% trans %}DNS DMARC entry{% endtrans %}</th>
<td>{{ macros.clip("dns_dmark") }}<pre id="dns_dmark" class="pre-config border bg-light">{{ domain.dns_dmarc }}</pre></td> <td>
{{ macros.clip("dns_dmarc") }}<pre id="dns_dmarc" class="pre-config border bg-light">{{ domain.dns_dmarc }}</pre>
{{ macros.clip("dns_dmarc_report") }}<pre id="dns_dmarc_report" class="pre-config border bg-light">{{ domain.dns_dmarc_report }}</pre>
</td>
</tr> </tr>
{%- endif %} {%- endif %}
{%- set tlsa_record=domain.dns_tlsa %} {%- set tlsa_record=domain.dns_tlsa %}
@ -58,12 +61,11 @@
{%- endif %} {%- endif %}
<tr> <tr>
<th>{% trans %}DNS client auto-configuration (RFC6186) entries{% endtrans %}</th> <th>{% trans %}DNS client auto-configuration (RFC6186) entries{% endtrans %}</th>
<td> <td>{{ macros.clip("dns_autoconfig") }}<pre id="dns_autoconfig" class="pre-config border bg-light">
{{ macros.clip("dns_autoconfig") }}<pre id="dns_autoconfig" class="pre-config border bg-light">
{%- for line in domain.dns_autoconfig %} {%- for line in domain.dns_autoconfig %}
{{ line }} {{ line }}
{%- endfor -%} {%- endfor -%}
</pre></td> </pre></td>
</tr> </tr>
{%- endcall %} {%- endcall %}
{%- endblock %} {%- endblock %}

@ -1,4 +1,4 @@
from mailu import models from mailu import models, utils
from mailu.ui import ui, forms, access from mailu.ui import ui, forms, access
from flask import current_app as app from flask import current_app as app

@ -17,10 +17,12 @@ from multiprocessing import Value
from mailu import limiter from mailu import limiter
from flask import current_app as app
import flask import flask
import flask_login import flask_login
import flask_migrate import flask_migrate
import flask_babel import flask_babel
import ipaddress
import redis import redis
from flask.sessions import SessionMixin, SessionInterface from flask.sessions import SessionMixin, SessionInterface
@ -57,19 +59,30 @@ def has_dane_record(domain, timeout=10):
# If the DNSSEC data is invalid and the DNS resolver is DNSSEC enabled # If the DNSSEC data is invalid and the DNS resolver is DNSSEC enabled
# we will receive this non-specific exception. The safe behaviour is to # we will receive this non-specific exception. The safe behaviour is to
# accept to defer the email. # accept to defer the email.
flask.current_app.logger.warn(f'Unable to lookup the TLSA record for {domain}. Is the DNSSEC zone okay on https://dnsviz.net/d/{domain}/dnssec/?') app.logger.warn(f'Unable to lookup the TLSA record for {domain}. Is the DNSSEC zone okay on https://dnsviz.net/d/{domain}/dnssec/?')
return flask.current_app.config['DEFER_ON_TLS_ERROR'] return app.config['DEFER_ON_TLS_ERROR']
except dns.exception.Timeout: except dns.exception.Timeout:
flask.current_app.logger.warn(f'Timeout while resolving the TLSA record for {domain} ({timeout}s).') app.logger.warn(f'Timeout while resolving the TLSA record for {domain} ({timeout}s).')
except dns.resolver.NXDOMAIN: except dns.resolver.NXDOMAIN:
pass # this is expected, not TLSA record is fine pass # this is expected, not TLSA record is fine
except Exception as e: except Exception as e:
flask.current_app.logger.error(f'Error while looking up the TLSA record for {domain} {e}') app.logger.error(f'Error while looking up the TLSA record for {domain} {e}')
pass pass
# Rate limiter # Rate limiter
limiter = limiter.LimitWraperFactory() limiter = limiter.LimitWraperFactory()
def extract_network_from_ip(ip):
n = ipaddress.ip_network(ip)
if n.version == 4:
return str(n.supernet(prefixlen_diff=(32-int(app.config["AUTH_RATELIMIT_IP_V4_MASK"]))).network_address)
else:
return str(n.supernet(prefixlen_diff=(128-int(app.config["AUTH_RATELIMIT_IP_V6_MASK"]))).network_address)
def is_exempt_from_ratelimits(ip):
ip = ipaddress.ip_address(ip)
return any(ip in cidr for cidr in app.config['AUTH_RATELIMIT_EXEMPTION'])
# Application translation # Application translation
babel = flask_babel.Babel() babel = flask_babel.Babel()
@ -77,8 +90,8 @@ babel = flask_babel.Babel()
def get_locale(): def get_locale():
""" selects locale for translation """ """ selects locale for translation """
language = flask.session.get('language') language = flask.session.get('language')
if not language in flask.current_app.config.translations: if not language in app.config.translations:
language = flask.request.accept_languages.best_match(flask.current_app.config.translations.keys()) language = flask.request.accept_languages.best_match(app.config.translations.keys())
flask.session['language'] = language flask.session['language'] = language
return language return language
@ -475,7 +488,7 @@ class MailuSessionExtension:
with cleaned.get_lock(): with cleaned.get_lock():
if not cleaned.value: if not cleaned.value:
cleaned.value = True cleaned.value = True
flask.current_app.logger.info('cleaning session store') app.logger.info('cleaning session store')
MailuSessionExtension.cleanup_sessions(app) MailuSessionExtension.cleanup_sessions(app)
app.before_first_request(cleaner) app.before_first_request(cleaner)

@ -264,6 +264,7 @@ http {
location /internal { location /internal {
internal; internal;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Authorization $http_authorization; proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization; proxy_pass_header Authorization;
proxy_pass http://$admin; proxy_pass http://$admin;

@ -1,17 +1,8 @@
# This configuration was copied from Mailinabox. The original version is available at: # This configuration was copied from Mailinabox. The original version is available at:
# https://raw.githubusercontent.com/mail-in-a-box/mailinabox/master/conf/postfix_outgoing_mail_header_filters # https://raw.githubusercontent.com/mail-in-a-box/mailinabox/master/conf/postfix_outgoing_mail_header_filters
# Remove the first line of the Received: header. Note that we cannot fully remove the Received: header # Remove typically private information.
# because OpenDKIM requires that a header be present when signing outbound mail. The first line is /^\s*(Received|User-Agent|X-(Enigmail|Mailer|Originating-IP|Pgp-Agent)):/ IGNORE
# where the user's home IP address would be.
/^\s*Received:[^\n]*(.*)/ REPLACE Received: from authenticated-user ({{OUTCLEAN}} [{{OUTCLEAN_ADDRESS}}])$1
# Remove other typically private information.
/^\s*User-Agent:/ IGNORE
/^\s*X-Enigmail:/ IGNORE
/^\s*X-Mailer:/ IGNORE
/^\s*X-Originating-IP:/ IGNORE
/^\s*X-Pgp-Agent:/ IGNORE
# The Mime-Version header can leak the user agent too, e.g. in Mime-Version: 1.0 (Mac OS X Mail 8.1 \(2010.6\)). # The Mime-Version header can leak the user agent too, e.g. in Mime-Version: 1.0 (Mac OS X Mail 8.1 \(2010.6\)).
/^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1 /^\s*(Mime-Version:\s*[0-9\.]+)\s.+/ REPLACE $1

@ -46,15 +46,6 @@ os.environ["FRONT_ADDRESS"] = system.get_host_address_from_environment("FRONT",
os.environ["ADMIN_ADDRESS"] = system.get_host_address_from_environment("ADMIN", "admin") os.environ["ADMIN_ADDRESS"] = system.get_host_address_from_environment("ADMIN", "admin")
os.environ["ANTISPAM_MILTER_ADDRESS"] = system.get_host_address_from_environment("ANTISPAM_MILTER", "antispam:11332") os.environ["ANTISPAM_MILTER_ADDRESS"] = system.get_host_address_from_environment("ANTISPAM_MILTER", "antispam:11332")
os.environ["LMTP_ADDRESS"] = system.get_host_address_from_environment("LMTP", "imap:2525") os.environ["LMTP_ADDRESS"] = system.get_host_address_from_environment("LMTP", "imap:2525")
os.environ["OUTCLEAN"] = os.environ["HOSTNAMES"].split(",")[0]
try:
_to_lookup = os.environ["OUTCLEAN"]
# Ensure we lookup a FQDN: @see #1884
if not _to_lookup.endswith('.'):
_to_lookup += '.'
os.environ["OUTCLEAN_ADDRESS"] = system.resolve_hostname(_to_lookup)
except:
os.environ["OUTCLEAN_ADDRESS"] = "10.10.10.10"
for postfix_file in glob.glob("/conf/*.cf"): for postfix_file in glob.glob("/conf/*.cf"):
conf.jinja(postfix_file, os.environ, os.path.join("/etc/postfix", os.path.basename(postfix_file))) conf.jinja(postfix_file, os.environ, os.path.join("/etc/postfix", os.path.basename(postfix_file)))

@ -1,4 +1,6 @@
try_fallback = true; try_fallback = false;
path = "/dkim/$domain.$selector.key";
selector = "dkim"
use_esld = false; use_esld = false;
allow_username_mismatch = true;
use_vault = true;
vault_url = "http://{{ ADMIN_ADDRESS }}/internal/rspamd/vault";
vault_token = "mailu";

@ -1,4 +1,6 @@
try_fallback = true; try_fallback = false;
path = "/dkim/$domain.$selector.key";
use_esld = false; use_esld = false;
allow_username_mismatch = true; allow_username_mismatch = true;
use_vault = true;
vault_url = "http://{{ ADMIN_ADDRESS }}/internal/rspamd/vault";
vault_token = "mailu";

@ -11,6 +11,7 @@ log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "WARNING"))
# Actual startup script # Actual startup script
os.environ["REDIS_ADDRESS"] = system.get_host_address_from_environment("REDIS", "redis") os.environ["REDIS_ADDRESS"] = system.get_host_address_from_environment("REDIS", "redis")
os.environ["ADMIN_ADDRESS"] = system.get_host_address_from_environment("ADMIN", "admin")
if os.environ.get("ANTIVIRUS") == 'clamav': if os.environ.get("ANTIVIRUS") == 'clamav':
os.environ["ANTIVIRUS_ADDRESS"] = system.get_host_address_from_environment("ANTIVIRUS", "antivirus:3310") os.environ["ANTIVIRUS_ADDRESS"] = system.get_host_address_from_environment("ANTIVIRUS", "antivirus:3310")

@ -39,14 +39,25 @@ address.
The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses that are allowed to send emails from any existing address (spoofing the sender). The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses that are allowed to send emails from any existing address (spoofing the sender).
The ``AUTH_RATELIMIT`` holds a security setting for fighting attackers that The ``AUTH_RATELIMIT_IP`` (default: 60/hour) holds a security setting for fighting
try to guess user passwords. The value is the limit of failed authentication attempts attackers that waste server resources by trying to guess user passwords (typically
that a single IP address can perform against IMAP, POP and SMTP authentication endpoints. using a password spraying attack). The value defines the limit of authentication
attempts that will be processed on non-existing accounts for a specific IP subnet
(as defined in ``AUTH_RATELIMIT_IP_V4_MASK`` and ``AUTH_RATELIMIT_IP_V6_MASK`` below).
If ``AUTH_RATELIMIT_SUBNET`` is ``True`` (default: False), the ``AUTH_RATELIMIT`` The ``AUTH_RATELIMIT_USER`` (default: 100/day) holds a security setting for fighting
rules does also apply to auth requests coming from ``SUBNET``, especially for the webmail. attackers that attempt to guess a user's password (typically using a password
If you disable this, ensure that the rate limit on the webmail is enforced in a different bruteforce attack). The value defines the limit of authentication attempts allowed
way (e.g. roundcube plug-in), otherwise an attacker can simply bypass the limit using webmail. for any given account within a specific timeframe.
The ``AUTH_RATELIMIT_EXEMPTION_LENGTH`` (default: 86400) is the number of seconds
after a successful login for which a specific IP address is exempted from rate limits.
This ensures that users behind a NAT don't get locked out when a single client is
misconfigured... but also potentially allow for users to attack each-other.
The ``AUTH_RATELIMIT_EXEMPTION`` (default: '') is a comma separated list of network
CIDRs that won't be subject to any form of rate limiting. Specifying ``0.0.0.0/0, ::/0``
there is a good way to disable rate limiting altogether.
The ``TLS_FLAVOR`` sets how Mailu handles TLS connections. Setting this value to The ``TLS_FLAVOR`` sets how Mailu handles TLS connections. Setting this value to
``notls`` will cause Mailu not to server any web content! More on :ref:`tls_flavor`. ``notls`` will cause Mailu not to server any web content! More on :ref:`tls_flavor`.
@ -93,9 +104,10 @@ go and fetch new email if available. Do not use too short delays if you do not
want to be blacklisted by external services, but not too long delays if you want to be blacklisted by external services, but not too long delays if you
want to receive your email in time. want to receive your email in time.
The ``RECIPIENT_DELIMITER`` is a character used to delimit localpart from a The ``RECIPIENT_DELIMITER`` is a list of characters used to delimit localpart
custom address part. For instance, if set to ``+``, users can use addresses from a custom address part. For instance, if set to ``+-``, users can use
like ``localpart+custom@domain.tld`` to deliver mail to ``localpart@domain.tld``. addresses like ``localpart+custom@example.com`` or ``localpart-custom@example.com``
to deliver mail to ``localpart@example.com``.
This is useful to provide external parties with different email addresses and This is useful to provide external parties with different email addresses and
later classify incoming mail based on the custom part. later classify incoming mail based on the custom part.

@ -394,6 +394,58 @@ Mailu can serve an `MTA-STS policy`_; To configure it you will need to:
.. _`1798`: https://github.com/Mailu/Mailu/issues/1798 .. _`1798`: https://github.com/Mailu/Mailu/issues/1798
.. _`MTA-STS policy`: https://datatracker.ietf.org/doc/html/rfc8461 .. _`MTA-STS policy`: https://datatracker.ietf.org/doc/html/rfc8461
How do I setup client autoconfiguration?
````````````````````````````````````````
Mailu can serve an `XML file for autoconfiguration`_; To configure it you will need to:
1. add ``autoconfig.example.com`` to the ``HOSTNAMES`` configuration variable (and ensure that a valid SSL certificate is available for it; this may mean restarting your smtp container)
2. configure an override with the policy itself; for example, your ``overrides/nginx/autoconfiguration.conf`` could read:
.. code-block:: bash
location ^~ /mail/config-v1.1.xml {
return 200 "<?xml version=\"1.0\"?>
<clientConfig version=\"1.1\">
<emailProvider id=\"%EMAILDOMAIN%\">
<domain>%EMAILDOMAIN%</domain>
<displayName>Email</displayName>
<displayShortName>Email</displayShortName>
<incomingServer type=\"imap\">
<hostname>mailu.example.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
</incomingServer>
<outgoingServer type=\"smtp\">
<hostname>mailu.example.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILADDRESS%</username>
<authentication>password-cleartext</authentication>
<addThisServer>true</addThisServer>
<useGlobalPreferredServer>true</useGlobalPreferredServer>
</outgoingServer>
<documentation url=\"https://mailu.example.com/admin/ui/client\">
<descr lang=\"en\">Configure your email client</descr>
</documentation>
</emailProvider>
</clientConfig>\r\n";
}
3. setup the appropriate DNS/CNAME record (``autoconfig.example.com`` -> ``mailu.example.com``).
*issue reference:* `224`_.
.. _`224`: https://github.com/Mailu/Mailu/issues/224
.. _`XML file for autoconfiguration`: https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
Technical issues Technical issues
---------------- ----------------

@ -100,6 +100,9 @@ https://github.com/moby/moby/issues/25526#issuecomment-336363408
### Don't create an open relay ! ### Don't create an open relay !
As a side effect of this ingress mode "feature", make sure that the ingress subnet is not in your RELAYHOST, otherwise you would create an smtp open relay :-( As a side effect of this ingress mode "feature", make sure that the ingress subnet is not in your RELAYHOST, otherwise you would create an smtp open relay :-(
### Ratelimits
When using ingress mode you probably want to disable rate limits, because all requests originate from the same ip address. Otherwise automatic login attempts can easily DoS the legitimate users.
## Scalability ## Scalability
- smtp and imap are scalable - smtp and imap are scalable

@ -1,19 +1,20 @@
server: server:
verbosity: 1 verbosity: 1
interface: 0.0.0.0 interface: 0.0.0.0
interface: ::0 {{ 'interface: ::0' if SUBNET6 }}
logfile: "" logfile: ""
do-ip4: yes do-ip4: yes
do-ip6: yes do-ip6: {{ 'yes' if SUBNET6 else 'no' }}
do-udp: yes do-udp: yes
do-tcp: yes do-tcp: yes
do-daemonize: no do-daemonize: no
access-control: {{ SUBNET }} allow access-control: {{ SUBNET }} allow
{{ 'access-control: {{ SUBNET6 }} allow' if SUBNET6 }}
directory: "/etc/unbound" directory: "/etc/unbound"
username: unbound username: unbound
auto-trust-anchor-file: trusted-key.key auto-trust-anchor-file: trusted-key.key
root-hints: "/etc/unbound/root.hints" root-hints: "/etc/unbound/root.hints"
hide-identity: yes hide-identity: yes
hide-version: yes hide-version: yes
max-udp-size: 4096 cache-min-ttl: 300
msg-buffer-size: 65552

@ -29,9 +29,14 @@ POSTMASTER={{ postmaster }}
# Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt) # Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt)
TLS_FLAVOR={{ tls_flavor }} TLS_FLAVOR={{ tls_flavor }}
# Authentication rate limit (per source IP address) # Authentication rate limit per IP (per /24 on ipv4 and /56 on ipv6)
{% if auth_ratelimit_pm > '0' %} {% if auth_ratelimit_ip > '0' %}
AUTH_RATELIMIT={{ auth_ratelimit_pm }}/minute AUTH_RATELIMIT_IP={{ auth_ratelimit_ip }}/hour
{% endif %}
# Authentication rate limit per user (regardless of the source-IP)
{% if auth_ratelimit_user > '0' %}
AUTH_RATELIMIT_USER={{ auth_ratelimit_user }}/day
{% endif %} {% endif %}
# Opt-out of statistics, replace with "True" to opt out # Opt-out of statistics, replace with "True" to opt out
@ -150,9 +155,8 @@ DOMAIN_REGISTRATION=true
# Docker-compose project name, this will prepended to containers names. # Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME={{ compose_project_name or 'mailu' }} COMPOSE_PROJECT_NAME={{ compose_project_name or 'mailu' }}
# Default password scheme used for newly created accounts and changed passwords # Number of rounds used by the password hashing scheme
# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) CREDENTIAL_ROUNDS=12
PASSWORD_SCHEME={{ password_scheme or 'PBKDF2' }}
# Header to take the real ip from # Header to take the real ip from
REAL_IP_HEADER={{ real_ip_header }} REAL_IP_HEADER={{ real_ip_header }}

@ -48,10 +48,18 @@ Or in plain english: if receivers start to classify your mail as spam, this post
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Authentication rate limit (per source IP address)</label> <label>Authentication rate limit per IP for failed login attempts or non-existing accounts</label>
<!-- Validates number input only --> <!-- Validates number input only -->
<p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_pm" <p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_ip"
value="10000" required > / minute value="60" required > / hour
</p>
</div>
<div class="form-group">
<label>Authentication rate limit per user</label>
<!-- Validates number input only -->
<p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_user"
value="100" required > / day
</p> </p>
</div> </div>

@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
# Docker-compose project name, this will prepended to containers names. # Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME=mailu COMPOSE_PROJECT_NAME=mailu
# Default password scheme used for newly created accounts and changed passwords
# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT)
PASSWORD_SCHEME=PBKDF2
# Header to take the real ip from # Header to take the real ip from
REAL_IP_HEADER= REAL_IP_HEADER=

@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
# Docker-compose project name, this will prepended to containers names. # Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME=mailu COMPOSE_PROJECT_NAME=mailu
# Default password scheme used for newly created accounts and changed passwords
# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT)
PASSWORD_SCHEME=PBKDF2
# Header to take the real ip from # Header to take the real ip from
REAL_IP_HEADER= REAL_IP_HEADER=

@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
# Docker-compose project name, this will prepended to containers names. # Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME=mailu COMPOSE_PROJECT_NAME=mailu
# Default password scheme used for newly created accounts and changed passwords
# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT)
PASSWORD_SCHEME=PBKDF2
# Header to take the real ip from # Header to take the real ip from
REAL_IP_HEADER= REAL_IP_HEADER=

@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
# Docker-compose project name, this will prepended to containers names. # Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME=mailu COMPOSE_PROJECT_NAME=mailu
# Default password scheme used for newly created accounts and changed passwords
# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT)
PASSWORD_SCHEME=PBKDF2
# Header to take the real ip from # Header to take the real ip from
REAL_IP_HEADER= REAL_IP_HEADER=

@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
# Docker-compose project name, this will prepended to containers names. # Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME=mailu COMPOSE_PROJECT_NAME=mailu
# Default password scheme used for newly created accounts and changed passwords
# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT)
PASSWORD_SCHEME=PBKDF2
# Header to take the real ip from # Header to take the real ip from
REAL_IP_HEADER= REAL_IP_HEADER=

@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
# Docker-compose project name, this will prepended to containers names. # Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME=mailu COMPOSE_PROJECT_NAME=mailu
# Default password scheme used for newly created accounts and changed passwords
# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT)
PASSWORD_SCHEME=PBKDF2
# Header to take the real ip from # Header to take the real ip from
REAL_IP_HEADER= REAL_IP_HEADER=

@ -0,0 +1 @@
Make the rate limit apply to a subnet rather than a specific IP (/24 for v4 and /56 for v6)

@ -0,0 +1 @@
Fix rate-limiting on /webdav/

@ -0,0 +1 @@
Refactor the rate limiter to ensure that it performs as intented.

@ -0,0 +1 @@
Log authentication attempts on the admin portal

@ -0,0 +1,3 @@
Make unbound work with ipv6
Add a cache-min-ttl of 5minutes
Enable qname minimisation (privacy)

@ -0,0 +1 @@
Disable the login page if SESSION_COOKIE_SECURE is incompatible with how Mailu is accessed as this seems to be a common misconfiguration.

@ -0,0 +1 @@
Derive a new subkey (from SECRET_KEY) for SRS

@ -0,0 +1 @@
allow sending emails as user+detail@domain.tld

@ -0,0 +1 @@
rspamd: get dkim keys via REST API instead of filesystem

@ -0,0 +1 @@
Document how to setup client autoconfig using an override

@ -0,0 +1 @@
Remove the Received header with PRIMARY_HOSTNAME [PUBLIC_IP]

@ -11,7 +11,8 @@ FROM build_${QEMU}
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
python3 curl python3-pip git python3-multidict \ python3 curl python3-pip git python3-multidict \
&& rm -rf /var/lib/apt/lists \ && rm -rf /var/lib/apt/lists \
&& echo "ServerSignature Off" >> /etc/apache2/apache2.conf && echo "ServerSignature Off\nServerName roundcube" >> /etc/apache2/apache2.conf \
&& sed -i 's,CustomLog.*combined$,\0 "'"expr=!(%{HTTP_USER_AGENT}=='health'\&\&(-R '127.0.0.1/8' || -R '::1'))"'",' /etc/apache2/sites-available/000-default.conf
# Shared layer between nginx, dovecot, postfix, postgresql, rspamd, unbound, rainloop, roundcube # Shared layer between nginx, dovecot, postfix, postgresql, rspamd, unbound, rainloop, roundcube
RUN pip3 install socrate RUN pip3 install socrate
@ -33,13 +34,17 @@ RUN apt-get update && apt-get install -y \
&& mv roundcubemail-* html \ && mv roundcubemail-* html \
&& mv carddav html/plugins/ \ && mv carddav html/plugins/ \
&& cd html \ && cd html \
&& rm -rf CHANGELOG INSTALL LICENSE README.md UPGRADING composer.json-dist installer \ && rm -rf CHANGELOG INSTALL LICENSE README.md UPGRADING composer.json-dist installer composer.* \
&& sed -i 's,mod_php5.c,mod_php7.c,g' .htaccess \ && sed -i 's,mod_php5.c,mod_php7.c,g' .htaccess \
&& sed -i 's,^php_value.*post_max_size,#&,g' .htaccess \ && sed -i 's,^php_value.*post_max_size,#&,g' .htaccess \
&& sed -i 's,^php_value.*upload_max_filesize,#&,g' .htaccess \ && sed -i 's,^php_value.*upload_max_filesize,#&,g' .htaccess \
&& chown -R www-data: logs temp \ && ln -sf index.php /var/www/html/sso.php \
&& ln -sf /dev/stderr /var/www/html/logs/errors.log \
&& chown -R root:root . \
&& chown www-data:www-data logs temp \
&& chmod -R a+rX . \
&& rm -rf /var/lib/apt/lists \ && rm -rf /var/lib/apt/lists \
&& a2enmod deflate expires headers && a2enmod rewrite deflate expires headers
COPY php.ini /php.ini COPY php.ini /php.ini
COPY config.inc.php /var/www/html/config/ COPY config.inc.php /var/www/html/config/
@ -51,4 +56,4 @@ VOLUME ["/data"]
CMD /start.py CMD /start.py
HEALTHCHECK CMD curl -f -L http://localhost/ || exit 1 HEALTHCHECK CMD curl -f -L -H 'User-Agent: health' http://localhost/ || exit 1

@ -52,6 +52,12 @@ class mailu extends rcube_plugin
} }
function login_failed($args) function login_failed($args)
{ {
$ua = $_SERVER['HTTP_USER_AGENT'];
$ra = $_SERVER['REMOTE_ADDR'];
if ($ua == 'health' and ($ra == '127.0.0.1' or $ra == '::1')) {
echo "OK";
exit;
}
header('Location: sso.php'); header('Location: sso.php');
exit(); exit();
} }

@ -34,11 +34,7 @@ else:
conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini") conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini")
# Create dirs, setup permissions # Create dirs, setup permissions
os.system("mkdir -p /data/gpg /var/www/html/logs") os.system("mkdir -p /data/gpg")
os.system("touch /var/www/html/logs/errors.log")
os.system("chown -R www-data:www-data /var/www/html/logs")
os.system("chmod -R a+rX /var/www/html/")
os.system("ln -sf /var/www/html/index.php /var/www/html/sso.php")
try: try:
print("Initializing database") print("Initializing database")
@ -61,8 +57,5 @@ except subprocess.CalledProcessError as e:
# Setup database permissions # Setup database permissions
os.system("chown -R www-data:www-data /data") os.system("chown -R www-data:www-data /data")
# Tail roundcube logs
subprocess.Popen(["tail", "-f", "-n", "0", "/var/www/html/logs/errors.log"])
# Run apache # Run apache
os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"]) os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"])

Loading…
Cancel
Save