mirror of
https://github.com/optim-enterprises-bv/Mailu.git
synced 2025-11-02 02:57:56 +00:00
Merge branch 'master' of github.com:Diman0/Mailu into fix-sso-1929
This commit is contained in:
@@ -13,7 +13,7 @@ COPY webpack.config.js ./
|
||||
COPY assets ./assets
|
||||
RUN set -eu \
|
||||
&& 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; \
|
||||
done \
|
||||
&& node_modules/.bin/webpack-cli --color
|
||||
|
||||
@@ -66,5 +66,12 @@ $('document').ready(function() {
|
||||
// init clipboard.js
|
||||
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.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.srs_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('SRS_KEY', 'utf-8'), 'sha256').digest()
|
||||
|
||||
# Initialize list of translations
|
||||
config.translations = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
|
||||
from datetime import timedelta
|
||||
from socrate import system
|
||||
import ipaddress
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
# Specific to the admin UI
|
||||
@@ -36,8 +37,12 @@ DEFAULT_CONFIG = {
|
||||
'TLS_FLAVOR': 'cert',
|
||||
'INBOUND_TLS_ENFORCE': False,
|
||||
'DEFER_ON_TLS_ERROR': True,
|
||||
'AUTH_RATELIMIT': '1000/minute;10000/hour',
|
||||
'AUTH_RATELIMIT_SUBNET': False,
|
||||
'AUTH_RATELIMIT_IP': '60/hour',
|
||||
'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,
|
||||
# Mail settings
|
||||
'DMARC_RUA': None,
|
||||
@@ -49,6 +54,7 @@ DEFAULT_CONFIG = {
|
||||
'DKIM_PATH': '/dkim/{domain}.{selector}.key',
|
||||
'DEFAULT_QUOTA': 1000000000,
|
||||
'MESSAGE_RATELIMIT': '200/day',
|
||||
'RECIPIENT_DELIMITER': '',
|
||||
# Web settings
|
||||
'SITENAME': 'Mailu',
|
||||
'WEBSITE': 'https://mailu.io',
|
||||
@@ -148,6 +154,7 @@ class ConfigManager(dict):
|
||||
self.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME']))
|
||||
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['HOSTNAME'] = hostnames[0]
|
||||
# update the app config itself
|
||||
|
||||
@@ -5,6 +5,7 @@ import re
|
||||
import urllib
|
||||
import ipaddress
|
||||
import socket
|
||||
import sqlalchemy.exc
|
||||
import tenacity
|
||||
|
||||
SUPPORTED_AUTH_METHODS = ["none", "plain"]
|
||||
@@ -19,6 +20,11 @@ STATUSES = {
|
||||
"encryption": ("Must issue a STARTTLS command first", {
|
||||
"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):
|
||||
@@ -71,8 +77,8 @@ def handle_authentication(headers):
|
||||
}
|
||||
# Authenticated user
|
||||
elif method == "plain":
|
||||
service_port = int(urllib.parse.unquote(headers["Auth-Port"]))
|
||||
if service_port == 25:
|
||||
is_valid_user = False
|
||||
if headers["Auth-Port"] == '25':
|
||||
return {
|
||||
"Auth-Status": "AUTH not supported",
|
||||
"Auth-Error-Code": "502 5.5.1",
|
||||
@@ -84,25 +90,37 @@ def handle_authentication(headers):
|
||||
# we need to manually decode.
|
||||
raw_user_email = urllib.parse.unquote(headers["Auth-User"])
|
||||
raw_password = urllib.parse.unquote(headers["Auth-Pass"])
|
||||
user_email = 'invalid'
|
||||
try:
|
||||
user_email = raw_user_email.encode("iso8859-1").decode("utf8")
|
||||
password = raw_password.encode("iso8859-1").decode("utf8")
|
||||
ip = urllib.parse.unquote(headers["Client-Ip"])
|
||||
except:
|
||||
app.logger.warn(f'Received undecodable user/password from nginx: {raw_user_email!r}/{raw_password!r}')
|
||||
else:
|
||||
user = models.User.query.get(user_email)
|
||||
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-Port": port
|
||||
}
|
||||
try:
|
||||
user = models.User.query.get(user_email)
|
||||
is_valid_user = True
|
||||
except sqlalchemy.exc.StatementError as exc:
|
||||
exc = str(exc).split('\n', 1)[0]
|
||||
app.logger.warn(f'Invalid user {user_email!r}: {exc}')
|
||||
else:
|
||||
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")
|
||||
return {
|
||||
"Auth-Status": status,
|
||||
"Auth-Error-Code": code,
|
||||
"Auth-User": user_email,
|
||||
"Auth-User-Exists": is_valid_user,
|
||||
"Auth-Wait": 0
|
||||
}
|
||||
# Unexpected
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
__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_login
|
||||
import base64
|
||||
import ipaddress
|
||||
|
||||
|
||||
@internal.route("/auth/email")
|
||||
def nginx_authentication():
|
||||
""" 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"]
|
||||
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.headers['Auth-Status'] = 'Authentication rate limit from one source exceeded'
|
||||
response.headers['Auth-Error-Code'] = '451 4.3.2'
|
||||
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
|
||||
@@ -25,14 +23,27 @@ def nginx_authentication():
|
||||
response = flask.Response()
|
||||
for key, value in headers.items():
|
||||
response.headers[key] = str(value)
|
||||
if ("Auth-Status" not in headers) or (headers["Auth-Status"] != "OK"):
|
||||
limit_subnet = str(app.config["AUTH_RATELIMIT_SUBNET"]) != 'False'
|
||||
subnet = ipaddress.ip_network(app.config["SUBNET"])
|
||||
if limit_subnet or ipaddress.ip_address(client_ip) not in subnet:
|
||||
limiter.hit(flask.request.headers["Client-Ip"])
|
||||
is_valid_user = False
|
||||
if response.headers.get("Auth-User-Exists"):
|
||||
username = response.headers["Auth-User"]
|
||||
if utils.limiter.should_rate_limit_user(username, 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
|
||||
|
||||
|
||||
@internal.route("/auth/admin")
|
||||
def admin_authentication():
|
||||
""" Fails if the user is not an authenticated admin.
|
||||
@@ -60,15 +71,29 @@ def user_authentication():
|
||||
def basic_authentication():
|
||||
""" 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")
|
||||
if authorization and authorization.startswith("Basic "):
|
||||
encoded = authorization.replace("Basic ", "")
|
||||
user_email, password = base64.b64decode(encoded).split(b":", 1)
|
||||
user = models.User.query.get(user_email.decode("utf8"))
|
||||
if nginx.check_credentials(user, password.decode('utf-8'), flask.request.remote_addr, "web"):
|
||||
user_email = user_email.decode("utf8")
|
||||
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.headers["X-User"] = models.IdnaEmail.process_bind_param(flask_login, user.email, "")
|
||||
utils.limiter.exempt_ip_from_ratelimits(client_ip)
|
||||
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.headers["WWW-Authenticate"] = 'Basic realm="Login Required"'
|
||||
return response
|
||||
|
||||
@@ -108,7 +108,7 @@ def postfix_recipient_map(recipient):
|
||||
|
||||
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):
|
||||
try:
|
||||
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.
|
||||
"""
|
||||
srs = srslib.SRS(flask.current_app.config["SECRET_KEY"])
|
||||
srs = srslib.SRS(flask.current_app.srs_key)
|
||||
domain = flask.current_app.config["DOMAIN"]
|
||||
try:
|
||||
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)
|
||||
if localpart is None:
|
||||
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 = [*destination, *wildcard_senders] if destination else [*wildcard_senders]
|
||||
return flask.jsonify(",".join(destination)) if destination else flask.abort(404)
|
||||
|
||||
30
core/admin/mailu/internal/views/rspamd.py
Normal file
30
core/admin/mailu/internal/views/rspamd.py
Normal file
@@ -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.storage
|
||||
import limits.strategies
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
class LimitWrapper(object):
|
||||
""" Wraps a limit by providing the storage, item and identifiers
|
||||
@@ -31,4 +36,59 @@ class LimitWraperFactory(object):
|
||||
self.limiter = limits.strategies.MovingWindowRateLimiter(self.storage)
|
||||
|
||||
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):
|
||||
""" 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)
|
||||
if '@' in localpart:
|
||||
raise ValueError('email local part must not contain "@"')
|
||||
@@ -241,6 +243,13 @@ class Domain(Base):
|
||||
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"'
|
||||
|
||||
@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
|
||||
def dns_autoconfig(self):
|
||||
""" return list of auto configuration records (RFC6186) """
|
||||
@@ -560,6 +569,8 @@ class User(Base, Email):
|
||||
""" verifies password against stored hash
|
||||
and updates hash if outdated
|
||||
"""
|
||||
if password == '':
|
||||
return False
|
||||
cache_result = self._credential_cache.get(self.get_id())
|
||||
current_salt = self.password.split('$')[3] if len(self.password.split('$')) == 5 else None
|
||||
if cache_result and current_salt:
|
||||
|
||||
@@ -3,9 +3,11 @@ msgstr ""
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: POEditor.com\n"
|
||||
"X-Generator: Poedit 1.5.7\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
|
||||
msgid "Invalid email address."
|
||||
@@ -28,7 +30,7 @@ msgstr "密码"
|
||||
#: mailu/ui/forms.py:42 mailu/ui/templates/login.html:4
|
||||
#: mailu/ui/templates/sidebar.html:111
|
||||
msgid "Sign in"
|
||||
msgstr "注册"
|
||||
msgstr "登录"
|
||||
|
||||
#: mailu/ui/forms.py:46 mailu/ui/forms.py:56
|
||||
#: mailu/ui/templates/domain/details.html:27
|
||||
@@ -44,6 +46,14 @@ msgstr "最大用户数"
|
||||
msgid "Maximum alias count"
|
||||
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:128 mailu/ui/forms.py:140
|
||||
#: mailu/ui/templates/alias/list.html:21 mailu/ui/templates/domain/list.html:21
|
||||
@@ -57,10 +67,30 @@ msgstr "说明"
|
||||
msgid "Create"
|
||||
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
|
||||
msgid "Confirm password"
|
||||
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/templates/user/signup_domain.html:16
|
||||
msgid "Quota"
|
||||
@@ -74,10 +104,24 @@ msgstr "允许IMAP访问"
|
||||
msgid "Allow POP3 access"
|
||||
msgstr "允许POP3访问"
|
||||
|
||||
#: mailu/ui/forms.py:84
|
||||
msgid "Enabled"
|
||||
msgstr "启用"
|
||||
|
||||
#: mailu/ui/forms.py:85
|
||||
msgid "Save"
|
||||
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
|
||||
msgid "Displayed name"
|
||||
msgstr "显示名称"
|
||||
@@ -86,10 +130,23 @@ msgstr "显示名称"
|
||||
msgid "Enable spam filter"
|
||||
msgstr "启用垃圾邮件过滤"
|
||||
|
||||
#: mailu/ui/forms.py:80
|
||||
msgid "Spam filter threshold"
|
||||
#: mailu/ui/forms.py:99
|
||||
msgid "Spam filter tolerance"
|
||||
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
|
||||
msgid "Save settings"
|
||||
msgstr "保存设置"
|
||||
@@ -102,19 +159,6 @@ msgstr "检查密码"
|
||||
msgid "Update password"
|
||||
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
|
||||
msgid "Enable automatic reply"
|
||||
msgstr "启用自动回复"
|
||||
@@ -127,6 +171,22 @@ msgstr "回复主题"
|
||||
msgid "Reply body"
|
||||
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
|
||||
msgid "Alias"
|
||||
msgstr "别名"
|
||||
@@ -169,11 +229,44 @@ msgstr "启用TLS"
|
||||
msgid "Username"
|
||||
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
|
||||
msgid "Confirm action"
|
||||
msgstr "确认操作"
|
||||
|
||||
#: mailu/ui/templates/confirm.html:13
|
||||
#, python-format
|
||||
msgid "You are about to %(action)s. Please confirm your action."
|
||||
msgstr "即将%(action)s,请确认您的操作。"
|
||||
|
||||
@@ -185,54 +278,18 @@ msgstr "Docker错误"
|
||||
msgid "An error occurred while talking to the Docker server."
|
||||
msgstr "Docker服务器通信出错"
|
||||
|
||||
#: mailu/admin/templates/login.html:6
|
||||
msgid "Your account"
|
||||
msgstr "你的帐户"
|
||||
|
||||
#: mailu/ui/templates/login.html:8
|
||||
msgid "to access the administration tools"
|
||||
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 "最后更新"
|
||||
msgstr "访问管理工具"
|
||||
|
||||
#: mailu/ui/templates/sidebar.html:8
|
||||
msgid "My account"
|
||||
msgstr "我的帐户"
|
||||
msgstr "我的账户"
|
||||
|
||||
#: mailu/ui/templates/sidebar.html:11 mailu/ui/templates/user/list.html:34
|
||||
msgid "Settings"
|
||||
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
|
||||
msgid "Auto-reply"
|
||||
msgstr "自动回复"
|
||||
@@ -240,39 +297,71 @@ msgstr "自动回复"
|
||||
#: mailu/ui/templates/fetch/list.html:4 mailu/ui/templates/sidebar.html:26
|
||||
#: mailu/ui/templates/user/list.html:36
|
||||
msgid "Fetched accounts"
|
||||
msgstr "代收帐户"
|
||||
msgstr "代收账户"
|
||||
|
||||
#: mailu/ui/templates/sidebar.html:105
|
||||
msgid "Sign out"
|
||||
msgstr "登出"
|
||||
#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4
|
||||
msgid "Authentication tokens"
|
||||
msgstr "认证令牌"
|
||||
|
||||
#: mailu/ui/templates/sidebar.html:35
|
||||
msgid "Administration"
|
||||
msgstr "管理"
|
||||
|
||||
#: mailu/ui/templates/sidebar.html:44
|
||||
msgid "Announcement"
|
||||
msgstr "公告"
|
||||
|
||||
#: mailu/ui/templates/sidebar.html:49
|
||||
msgid "Administrators"
|
||||
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
|
||||
msgid "Mail domains"
|
||||
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
|
||||
msgid "Help"
|
||||
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
|
||||
msgid "We are still working on this feature!"
|
||||
msgstr "该功能开发中……"
|
||||
|
||||
#: mailu/ui/templates/admin/create.html:4
|
||||
msgid "Add a global administrator"
|
||||
msgstr "添加超级管理员"
|
||||
msgstr "添加全局管理员"
|
||||
|
||||
#: mailu/ui/templates/admin/list.html:4
|
||||
msgid "Global administrators"
|
||||
msgstr "超级管理员"
|
||||
msgstr "全局管理员"
|
||||
|
||||
#: mailu/ui/templates/admin/list.html:9
|
||||
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/user/list.html:24
|
||||
msgid "Created"
|
||||
msgstr "创建"
|
||||
msgstr "已创建"
|
||||
|
||||
#: 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
|
||||
@@ -337,6 +426,22 @@ msgstr "上次编辑"
|
||||
msgid "Edit"
|
||||
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/list.html:9
|
||||
msgid "New domain"
|
||||
@@ -344,11 +449,15 @@ msgstr "新域"
|
||||
|
||||
#: mailu/ui/templates/domain/details.html:4
|
||||
msgid "Domain details"
|
||||
msgstr "域详情"
|
||||
msgstr "域详细信息"
|
||||
|
||||
#: mailu/ui/templates/domain/details.html:15
|
||||
msgid "Regenerate keys"
|
||||
msgstr "重新生成密钥"
|
||||
msgstr "重新生成秘钥"
|
||||
|
||||
#: mailu/ui/templates/domain/details.html:17
|
||||
msgid "Generate keys"
|
||||
msgstr "生成秘钥"
|
||||
|
||||
#: mailu/ui/templates/domain/details.html:31
|
||||
msgid "DNS MX entry"
|
||||
@@ -392,7 +501,7 @@ msgstr "别名数量"
|
||||
|
||||
#: mailu/ui/templates/domain/list.html:28
|
||||
msgid "Details"
|
||||
msgstr "详情"
|
||||
msgstr "详细信息"
|
||||
|
||||
#: mailu/ui/templates/domain/list.html:35
|
||||
msgid "Users"
|
||||
@@ -406,26 +515,60 @@ msgstr "别名"
|
||||
msgid "Managers"
|
||||
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
|
||||
msgid "Add a fetched account"
|
||||
msgstr "添加一个代收帐户"
|
||||
msgstr "添加一个代收账户"
|
||||
|
||||
#: mailu/ui/templates/fetch/edit.html:4
|
||||
msgid "Update a fetched account"
|
||||
msgstr "更新代收帐户"
|
||||
msgstr "更新代收账户"
|
||||
|
||||
#: mailu/ui/templates/fetch/list.html:12
|
||||
msgid "Add an account"
|
||||
msgstr "添加一个帐户"
|
||||
msgstr "添加一个账户"
|
||||
|
||||
#: mailu/ui/templates/fetch/list.html:19
|
||||
msgid "Endpoint"
|
||||
msgstr "端点"
|
||||
|
||||
#: mailu/ui/templates/fetch/list.html:21
|
||||
msgid "Keep emails"
|
||||
msgstr "保留电子邮件"
|
||||
|
||||
#: mailu/ui/templates/fetch/list.html:22
|
||||
msgid "Last check"
|
||||
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
|
||||
msgid "Add a manager"
|
||||
msgstr "添加一个管理员"
|
||||
@@ -438,41 +581,49 @@ msgstr "管理员列表"
|
||||
msgid "Add manager"
|
||||
msgstr "添加管理员"
|
||||
|
||||
#: mailu/ui/forms.py:168
|
||||
msgid "Announcement subject"
|
||||
msgstr "公告主题"
|
||||
#: mailu/ui/templates/relay/create.html:4
|
||||
msgid "New relay domain"
|
||||
msgstr "新的中继域"
|
||||
|
||||
#: mailu/ui/forms.py:170
|
||||
msgid "Announcement body"
|
||||
msgstr "公告正文"
|
||||
#: mailu/ui/templates/relay/edit.html:4
|
||||
msgid "Edit relayd domain"
|
||||
msgstr "编辑中继域"
|
||||
|
||||
#: mailu/ui/forms.py:172
|
||||
msgid "Send"
|
||||
msgstr "发送"
|
||||
#: mailu/ui/templates/relay/list.html:4
|
||||
msgid "Relayed domain list"
|
||||
msgstr "中继域列表"
|
||||
|
||||
#: mailu/ui/templates/announcement.html:4
|
||||
msgid "Public announcement"
|
||||
msgstr "公告"
|
||||
#: mailu/ui/templates/relay/list.html:9
|
||||
msgid "New relayed domain"
|
||||
msgstr "新的中继域"
|
||||
|
||||
#: mailu/ui/templates/announcement.html:8
|
||||
msgid "from"
|
||||
msgstr "来自"
|
||||
#: mailu/ui/templates/token/create.html:4
|
||||
msgid "Create an authentication token"
|
||||
msgstr "创建一个认证令牌"
|
||||
|
||||
#: mailu/ui/templates/sidebar.html:44
|
||||
msgid "Announcement"
|
||||
msgstr "公告"
|
||||
#: mailu/ui/templates/token/list.html:12
|
||||
msgid "New token"
|
||||
msgstr "新令牌"
|
||||
|
||||
#: mailu/ui/templates/user/create.html:4
|
||||
msgid "New user"
|
||||
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
|
||||
msgid "Edit user"
|
||||
msgstr "编辑用户"
|
||||
|
||||
#: mailu/ui/templates/user/forward.html:4
|
||||
msgid "Forward emails"
|
||||
msgstr "转发电子邮件"
|
||||
msgstr "转发邮件"
|
||||
|
||||
#: mailu/ui/templates/user/list.html:4
|
||||
msgid "User list"
|
||||
@@ -492,201 +643,15 @@ msgstr "功能"
|
||||
|
||||
#: mailu/ui/templates/user/password.html:4
|
||||
msgid "Password update"
|
||||
msgstr "密码更新"
|
||||
msgstr "更新密码"
|
||||
|
||||
#: mailu/ui/templates/user/reply.html:4
|
||||
msgid "Automatic reply"
|
||||
msgstr "自动回复"
|
||||
|
||||
#: mailu/ui/forms.py:49
|
||||
msgid "Maximum user quota"
|
||||
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/settings.html:22
|
||||
msgid "Auto-forward"
|
||||
msgstr "自动转发"
|
||||
|
||||
#: mailu/ui/templates/user/signup_domain.html:8
|
||||
msgid "pick a domain for the new account"
|
||||
@@ -700,3 +665,14 @@ msgstr "域名"
|
||||
msgid "Available slots"
|
||||
msgstr "可用"
|
||||
|
||||
#~ msgid "Your account"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "Spam filter threshold"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "from"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ msgid "General settings"
|
||||
#~ msgstr ""
|
||||
@@ -46,7 +46,10 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
{%- endif %}
|
||||
{%- set tlsa_record=domain.dns_tlsa %}
|
||||
@@ -58,12 +61,11 @@
|
||||
{%- endif %}
|
||||
<tr>
|
||||
<th>{% trans %}DNS client auto-configuration (RFC6186) entries{% endtrans %}</th>
|
||||
<td>
|
||||
{{ macros.clip("dns_autoconfig") }}<pre id="dns_autoconfig" class="pre-config border bg-light">
|
||||
<td>{{ macros.clip("dns_autoconfig") }}<pre id="dns_autoconfig" class="pre-config border bg-light">
|
||||
{%- for line in domain.dns_autoconfig %}
|
||||
{{ line }}
|
||||
{%- endfor -%}
|
||||
</pre></td>
|
||||
</pre></td>
|
||||
</tr>
|
||||
{%- endcall %}
|
||||
{%- endblock %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from mailu import models
|
||||
from mailu import models, utils
|
||||
from mailu.ui import ui, forms, access
|
||||
|
||||
from flask import current_app as app
|
||||
|
||||
@@ -17,10 +17,12 @@ from multiprocessing import Value
|
||||
|
||||
from mailu import limiter
|
||||
|
||||
from flask import current_app as app
|
||||
import flask
|
||||
import flask_login
|
||||
import flask_migrate
|
||||
import flask_babel
|
||||
import ipaddress
|
||||
import redis
|
||||
|
||||
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
|
||||
# we will receive this non-specific exception. The safe behaviour is to
|
||||
# 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/?')
|
||||
return flask.current_app.config['DEFER_ON_TLS_ERROR']
|
||||
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 app.config['DEFER_ON_TLS_ERROR']
|
||||
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:
|
||||
pass # this is expected, not TLSA record is fine
|
||||
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
|
||||
|
||||
# Rate limiter
|
||||
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
|
||||
babel = flask_babel.Babel()
|
||||
|
||||
@@ -77,8 +90,8 @@ babel = flask_babel.Babel()
|
||||
def get_locale():
|
||||
""" selects locale for translation """
|
||||
language = flask.session.get('language')
|
||||
if not language in flask.current_app.config.translations:
|
||||
language = flask.request.accept_languages.best_match(flask.current_app.config.translations.keys())
|
||||
if not language in app.config.translations:
|
||||
language = flask.request.accept_languages.best_match(app.config.translations.keys())
|
||||
flask.session['language'] = language
|
||||
return language
|
||||
|
||||
@@ -475,7 +488,7 @@ class MailuSessionExtension:
|
||||
with cleaned.get_lock():
|
||||
if not cleaned.value:
|
||||
cleaned.value = True
|
||||
flask.current_app.logger.info('cleaning session store')
|
||||
app.logger.info('cleaning session store')
|
||||
MailuSessionExtension.cleanup_sessions(app)
|
||||
|
||||
app.before_first_request(cleaner)
|
||||
|
||||
@@ -264,6 +264,7 @@ http {
|
||||
location /internal {
|
||||
internal;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_pass_header Authorization;
|
||||
proxy_pass http://$admin;
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
# 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
|
||||
|
||||
# Remove the first line of the Received: header. Note that we cannot fully remove the Received: header
|
||||
# because OpenDKIM requires that a header be present when signing outbound mail. The first line is
|
||||
# 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
|
||||
# Remove typically private information.
|
||||
/^\s*(Received|User-Agent|X-(Enigmail|Mailer|Originating-IP|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\)).
|
||||
/^\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["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["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"):
|
||||
conf.jinja(postfix_file, os.environ, os.path.join("/etc/postfix", os.path.basename(postfix_file)))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
try_fallback = true;
|
||||
path = "/dkim/$domain.$selector.key";
|
||||
selector = "dkim"
|
||||
try_fallback = 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;
|
||||
path = "/dkim/$domain.$selector.key";
|
||||
try_fallback = false;
|
||||
use_esld = false;
|
||||
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
|
||||
|
||||
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':
|
||||
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 ``AUTH_RATELIMIT`` holds a security setting for fighting attackers that
|
||||
try to guess user passwords. The value is the limit of failed authentication attempts
|
||||
that a single IP address can perform against IMAP, POP and SMTP authentication endpoints.
|
||||
The ``AUTH_RATELIMIT_IP`` (default: 60/hour) holds a security setting for fighting
|
||||
attackers that waste server resources by trying to guess user passwords (typically
|
||||
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``
|
||||
rules does also apply to auth requests coming from ``SUBNET``, especially for the webmail.
|
||||
If you disable this, ensure that the rate limit on the webmail is enforced in a different
|
||||
way (e.g. roundcube plug-in), otherwise an attacker can simply bypass the limit using webmail.
|
||||
The ``AUTH_RATELIMIT_USER`` (default: 100/day) holds a security setting for fighting
|
||||
attackers that attempt to guess a user's password (typically using a password
|
||||
bruteforce attack). The value defines the limit of authentication attempts allowed
|
||||
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
|
||||
``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 receive your email in time.
|
||||
|
||||
The ``RECIPIENT_DELIMITER`` is a character used to delimit localpart from a
|
||||
custom address part. For instance, if set to ``+``, users can use addresses
|
||||
like ``localpart+custom@domain.tld`` to deliver mail to ``localpart@domain.tld``.
|
||||
The ``RECIPIENT_DELIMITER`` is a list of characters used to delimit localpart
|
||||
from a custom address part. For instance, if set to ``+-``, users can use
|
||||
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
|
||||
later classify incoming mail based on the custom part.
|
||||
|
||||
|
||||
52
docs/faq.rst
52
docs/faq.rst
@@ -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
|
||||
.. _`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
|
||||
----------------
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ https://github.com/moby/moby/issues/25526#issuecomment-336363408
|
||||
### 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 :-(
|
||||
|
||||
### 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
|
||||
- smtp and imap are scalable
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
server:
|
||||
verbosity: 1
|
||||
interface: 0.0.0.0
|
||||
interface: ::0
|
||||
{{ 'interface: ::0' if SUBNET6 }}
|
||||
logfile: ""
|
||||
do-ip4: yes
|
||||
do-ip6: yes
|
||||
do-ip6: {{ 'yes' if SUBNET6 else 'no' }}
|
||||
do-udp: yes
|
||||
do-tcp: yes
|
||||
do-daemonize: no
|
||||
access-control: {{ SUBNET }} allow
|
||||
{{ 'access-control: {{ SUBNET6 }} allow' if SUBNET6 }}
|
||||
directory: "/etc/unbound"
|
||||
username: unbound
|
||||
auto-trust-anchor-file: trusted-key.key
|
||||
root-hints: "/etc/unbound/root.hints"
|
||||
hide-identity: yes
|
||||
hide-version: yes
|
||||
max-udp-size: 4096
|
||||
msg-buffer-size: 65552
|
||||
cache-min-ttl: 300
|
||||
|
||||
|
||||
@@ -29,9 +29,14 @@ POSTMASTER={{ postmaster }}
|
||||
# Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt)
|
||||
TLS_FLAVOR={{ tls_flavor }}
|
||||
|
||||
# Authentication rate limit (per source IP address)
|
||||
{% if auth_ratelimit_pm > '0' %}
|
||||
AUTH_RATELIMIT={{ auth_ratelimit_pm }}/minute
|
||||
# Authentication rate limit per IP (per /24 on ipv4 and /56 on ipv6)
|
||||
{% if auth_ratelimit_ip > '0' %}
|
||||
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 %}
|
||||
|
||||
# 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.
|
||||
COMPOSE_PROJECT_NAME={{ compose_project_name or 'mailu' }}
|
||||
|
||||
# Default password scheme used for newly created accounts and changed passwords
|
||||
# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT)
|
||||
PASSWORD_SCHEME={{ password_scheme or 'PBKDF2' }}
|
||||
# Number of rounds used by the password hashing scheme
|
||||
CREDENTIAL_ROUNDS=12
|
||||
|
||||
# Header to take the real ip from
|
||||
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 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 -->
|
||||
<p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_pm"
|
||||
value="10000" required > / minute
|
||||
<p><input class="form-control" style="width: 9%; display: inline;" type="number" name="auth_ratelimit_ip"
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
|
||||
# Docker-compose project name, this will prepended to containers names.
|
||||
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
|
||||
REAL_IP_HEADER=
|
||||
|
||||
|
||||
@@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
|
||||
# Docker-compose project name, this will prepended to containers names.
|
||||
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
|
||||
REAL_IP_HEADER=
|
||||
|
||||
|
||||
@@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
|
||||
# Docker-compose project name, this will prepended to containers names.
|
||||
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
|
||||
REAL_IP_HEADER=
|
||||
|
||||
|
||||
@@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
|
||||
# Docker-compose project name, this will prepended to containers names.
|
||||
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
|
||||
REAL_IP_HEADER=
|
||||
|
||||
|
||||
@@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
|
||||
# Docker-compose project name, this will prepended to containers names.
|
||||
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
|
||||
REAL_IP_HEADER=
|
||||
|
||||
|
||||
@@ -128,10 +128,6 @@ WEBSITE=https://mailu.io
|
||||
# Docker-compose project name, this will prepended to containers names.
|
||||
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
|
||||
REAL_IP_HEADER=
|
||||
|
||||
|
||||
1
towncrier/newsfragments/116.feature
Normal file
1
towncrier/newsfragments/116.feature
Normal file
@@ -0,0 +1 @@
|
||||
Make the rate limit apply to a subnet rather than a specific IP (/24 for v4 and /56 for v6)
|
||||
1
towncrier/newsfragments/1194.bugfix
Normal file
1
towncrier/newsfragments/1194.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Fix rate-limiting on /webdav/
|
||||
1
towncrier/newsfragments/1612.feature
Normal file
1
towncrier/newsfragments/1612.feature
Normal file
@@ -0,0 +1 @@
|
||||
Refactor the rate limiter to ensure that it performs as intented.
|
||||
1
towncrier/newsfragments/1926.feature
Normal file
1
towncrier/newsfragments/1926.feature
Normal file
@@ -0,0 +1 @@
|
||||
Log authentication attempts on the admin portal
|
||||
3
towncrier/newsfragments/1992.enhancement
Normal file
3
towncrier/newsfragments/1992.enhancement
Normal file
@@ -0,0 +1,3 @@
|
||||
Make unbound work with ipv6
|
||||
Add a cache-min-ttl of 5minutes
|
||||
Enable qname minimisation (privacy)
|
||||
1
towncrier/newsfragments/1996.enhancement
Normal file
1
towncrier/newsfragments/1996.enhancement
Normal file
@@ -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.
|
||||
1
towncrier/newsfragments/2002.enhancement
Normal file
1
towncrier/newsfragments/2002.enhancement
Normal file
@@ -0,0 +1 @@
|
||||
Derive a new subkey (from SECRET_KEY) for SRS
|
||||
1
towncrier/newsfragments/2007.enhancement
Normal file
1
towncrier/newsfragments/2007.enhancement
Normal file
@@ -0,0 +1 @@
|
||||
allow sending emails as user+detail@domain.tld
|
||||
1
towncrier/newsfragments/2017.enhancement
Normal file
1
towncrier/newsfragments/2017.enhancement
Normal file
@@ -0,0 +1 @@
|
||||
rspamd: get dkim keys via REST API instead of filesystem
|
||||
1
towncrier/newsfragments/224.enhancement
Normal file
1
towncrier/newsfragments/224.enhancement
Normal file
@@ -0,0 +1 @@
|
||||
Document how to setup client autoconfig using an override
|
||||
1
towncrier/newsfragments/466.feature
Normal file
1
towncrier/newsfragments/466.feature
Normal file
@@ -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 \
|
||||
python3 curl python3-pip git python3-multidict \
|
||||
&& 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
|
||||
RUN pip3 install socrate
|
||||
@@ -33,13 +34,17 @@ RUN apt-get update && apt-get install -y \
|
||||
&& mv roundcubemail-* html \
|
||||
&& mv carddav html/plugins/ \
|
||||
&& 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,^php_value.*post_max_size,#&,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 \
|
||||
&& a2enmod deflate expires headers
|
||||
&& a2enmod rewrite deflate expires headers
|
||||
|
||||
COPY php.ini /php.ini
|
||||
COPY config.inc.php /var/www/html/config/
|
||||
@@ -51,4 +56,4 @@ VOLUME ["/data"]
|
||||
|
||||
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)
|
||||
{
|
||||
$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');
|
||||
exit();
|
||||
}
|
||||
|
||||
@@ -34,11 +34,7 @@ else:
|
||||
conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini")
|
||||
|
||||
# Create dirs, setup permissions
|
||||
os.system("mkdir -p /data/gpg /var/www/html/logs")
|
||||
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")
|
||||
os.system("mkdir -p /data/gpg")
|
||||
|
||||
try:
|
||||
print("Initializing database")
|
||||
@@ -61,8 +57,5 @@ except subprocess.CalledProcessError as e:
|
||||
# Setup database permissions
|
||||
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
|
||||
os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"])
|
||||
|
||||
Reference in New Issue
Block a user