mirror of
https://github.com/optim-enterprises-bv/Mailu.git
synced 2025-11-03 11:37:47 +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
|
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)
|
||||||
|
|||||||
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
|
||||||
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
|
||||||
@@ -31,4 +36,59 @@ class LimitWraperFactory(object):
|
|||||||
self.limiter = limits.strategies.MovingWindowRateLimiter(self.storage)
|
self.limiter = limits.strategies.MovingWindowRateLimiter(self.storage)
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
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
|
.. _`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=
|
||||||
|
|
||||||
|
|||||||
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 \
|
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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user