diff --git a/.mergify.yml b/.mergify.yml index 6cd6a5a3..02e41922 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -27,7 +27,7 @@ pull_request_rules: - name: Trusted author and 1 approved review; trigger bors r+ conditions: - - author~=^(mergify|kaiyou|muhlemmer|mildred|HorayNarea|hoellen|ofthesun9|Nebukadneza|micw|lub|Diman0|3-w-c|decentral1se|ghostwheel42|nextgens|parisni)$ + - author~=^(mergify|kaiyou|muhlemmer|mildred|HorayNarea|hoellen|ofthesun9|Nebukadneza|micw|lub|Diman0|ghostwheel42|nextgens)$ - -title~=(WIP|wip) - -label~=^(status/wip|status/blocked|review/need2)$ - "#approved-reviews-by>=1" diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index 8fb3265d..fc3b6b61 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ - Mention an issue like: #001 - Auto close an issue like: closes #001 -## Prerequistes +## Prerequisites Before we can consider review and merge, please make sure the following list is done and checked. If an entry in not applicable, you can check it or remove it from the list. diff --git a/README.md b/README.md index c4354b28..4c19ad78 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Main features include: - **Web access**, multiple Webmails and administration interface - **User features**, aliases, auto-reply, auto-forward, fetched accounts - **Admin features**, global admins, announcements, per-domain delegation, quotas -- **Security**, enforced TLS, Letsencrypt!, outgoing DKIM, anti-virus scanner +- **Security**, enforced TLS, DANE, MTA-STS, Letsencrypt!, outgoing DKIM, anti-virus scanner - **Antispam**, auto-learn, greylisting, DMARC and SPF - **Freedom**, all FOSS components, no tracker included diff --git a/core/admin/Dockerfile b/core/admin/Dockerfile index fa75e8dc..1958ae61 100644 --- a/core/admin/Dockerfile +++ b/core/admin/Dockerfile @@ -1,40 +1,51 @@ # First stage to build assets -ARG DISTRO=alpine:3.14 +ARG DISTRO=alpine:3.14.2 ARG ARCH="" FROM ${ARCH}node:16 as assets -COPY --from=balenalib/rpi-alpine:3.14 /usr/bin/qemu-arm-static /usr/bin/qemu-arm-static COPY package.json ./ -RUN npm install +RUN set -eu \ + && npm config set update-notifier false \ + && npm install --no-fund -COPY ./webpack.config.js ./ -COPY ./assets ./assets -RUN mkdir static \ - && ./node_modules/.bin/webpack-cli +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; do \ + cp node_modules/datatables.net-plugins/i18n/${l#*:}.json assets/${l%:*}.json; \ + done \ + && node_modules/.bin/webpack-cli --color # Actual application FROM $DISTRO +COPY --from=balenalib/rpi-alpine:3.14 /usr/bin/qemu-arm-static /usr/bin/qemu-arm-static + +ENV TZ Etc/UTC + # python3 shared with most images -RUN apk add --no-cache \ - python3 py3-pip git bash \ - && pip3 install --upgrade pip +RUN set -eu \ + && apk add --no-cache python3 py3-pip py3-wheel git bash tzdata \ + && pip3 install --upgrade pip RUN mkdir -p /app WORKDIR /app COPY requirements-prod.txt requirements.txt -RUN apk add --no-cache openssl curl postgresql-libs mariadb-connector-c \ - && apk add --no-cache --virtual build-dep \ - openssl-dev libffi-dev python3-dev build-base postgresql-dev mariadb-connector-c-dev cargo \ - && pip3 install -r requirements.txt \ - && apk del --no-cache build-dep +RUN set -eu \ + && apk add --no-cache libressl curl postgresql-libs mariadb-connector-c \ + && apk add --no-cache --virtual build-dep libressl-dev libffi-dev python3-dev build-base postgresql-dev mariadb-connector-c-dev cargo \ + && pip install --upgrade pip \ + && pip install -r requirements.txt \ + && apk del --no-cache build-dep -COPY --from=assets static ./mailu/ui/static +COPY --from=assets static ./mailu/static COPY mailu ./mailu COPY migrations ./migrations COPY start.py /start.py +COPY audit.py /audit.py RUN pybabel compile -d mailu/translations @@ -44,4 +55,4 @@ ENV FLASK_APP mailu CMD /start.py -HEALTHCHECK CMD curl -f -L http://localhost/ui/login?next=ui.index || exit 1 +HEALTHCHECK CMD curl -f -L http://localhost/sso/login?next=ui.index || exit 1 diff --git a/core/admin/assets/app.css b/core/admin/assets/app.css index 8351eed8..84644900 100644 --- a/core/admin/assets/app.css +++ b/core/admin/assets/app.css @@ -1,23 +1,59 @@ -.select2-search--inline .select2-search__field:focus { - border: none; +/* mailu logo */ +.mailu-logo { + opacity: .8; +} +.bg-mailu-logo { + background-color: #2980b9!important; } -.sidebar h4 { - padding-left: 5px; - padding-right: 5px; - overflow: hidden; - text-overflow: ellipsis; +/* user image */ +.div-circle { + position: relative; + width: 2.1rem; + height: 2.1rem; + opacity: .8; + background-color: white; + border-radius: 50%; +} +.div-circle > i { + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) } -.sidebar-collapse .sidebar h4 { - display: none !important; +/* nice round preformatted configuration display */ +.pre-config { + padding: 9px; + margin: 0; + white-space: pre-wrap; + word-wrap: anywhere; + border-radius: 4px; } -.logo a { - color: #fff; +/* fieldset */ +legend { + font-size: inherit; +} +fieldset:disabled :not(legend) label { + opacity: .5; +} +fieldset:disabled .form-control:disabled { + color: gray; } -.sidebar-toggle { - padding: unset !important; +/* fix animation for icons in menu text */ +.sidebar .nav-link p i { + transition: margin-left .3s linear,opacity .3s ease,visibility .3s ease; } +/* fix select2 text color */ +.select2-container--default .select2-selection--multiple .select2-selection__choice { + color: black; +} + +/* range input spacing */ +.input-group-text { + margin-right: 1em; +} diff --git a/core/admin/assets/app.js b/core/admin/assets/app.js index 364f8429..03ea6215 100644 --- a/core/admin/assets/app.js +++ b/core/admin/assets/app.js @@ -1,17 +1,79 @@ require('./app.css'); -import 'admin-lte/plugins/select2/js/select2.js'; -import 'admin-lte/plugins/datatables/jquery.dataTables.js'; -import 'admin-lte/plugins/datatables-bs4/js/dataTables.bootstrap4.js'; -import 'admin-lte/plugins/datatables-responsive/js/dataTables.responsive.js'; -import 'admin-lte/plugins/datatables-responsive/js/responsive.bootstrap4.js'; +import logo from './mailu.png'; +import modules from "./*.json"; -jQuery("document").ready(function() { - jQuery(".mailselect").select2({ +// TODO: conditionally (or lazy) load select2 and dataTable +$('document').ready(function() { + + // intercept anchors with data-clicked attribute and open alternate location instead + $('[data-clicked]').click(function(e) { + e.preventDefault(); + window.location.href = $(this).data('clicked'); + }); + + // use post for language selection + $('#mailu-languages > a').click(function(e) { + e.preventDefault(); + $.post({ + url: $(this).attr('href'), + success: function() { + window.location = window.location.href; + }, + }); + }); + + // allow en-/disabling of inputs in fieldset with checkbox in legend + $('fieldset legend input[type=checkbox]').change(function() { + var fieldset = $(this).parents('fieldset'); + if (this.checked) { + fieldset.removeAttr('disabled'); + fieldset.find('input,textarea').not(this).removeAttr('disabled'); + } else { + fieldset.attr('disabled', ''); + fieldset.find('input,textarea').not(this).attr('disabled', ''); + } + }); + + // display of range input value + $('input[type=range]').each(function() { + var value_element = $('#'+this.id+'_value'); + if (value_element.length) { + value_element = $(value_element[0]); + var infinity = $(this).data('infinity'); + var step = $(this).attr('step'); + $(this).on('input', function() { + var num = (infinity && this.value == 0) ? '∞' : (this.value/step).toFixed(2); + if (num.endsWith('.00')) num = num.substr(0, num.length - 3); + value_element.text(num); + }).trigger('input'); + } + }); + + // init select2 + $('.mailselect').select2({ tags: true, - tokenSeparators: [',', ' '] + tokenSeparators: [',', ' '], }); - jQuery(".dataTable").DataTable({ - "responsive": true, + + // init dataTable + var d = $(document.documentElement); + $('.dataTable').DataTable({ + 'responsive': true, + language: { + url: d.data('static') + d.attr('lang') + '.json', + }, }); + + // 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); + } + }); + diff --git a/core/admin/assets/mailu.png b/core/admin/assets/mailu.png new file mode 100644 index 00000000..e4f5021f Binary files /dev/null and b/core/admin/assets/mailu.png differ diff --git a/core/admin/assets/vendor.js b/core/admin/assets/vendor.js index fd43d918..906448cf 100644 --- a/core/admin/assets/vendor.js +++ b/core/admin/assets/vendor.js @@ -1,22 +1,24 @@ -// jQuery -import jQuery from 'jquery'; -import 'admin-lte/plugins/select2/css/select2.css'; - -// bootstrap -// import 'bootstrap/less/bootstrap.less'; -// import 'bootstrap'; - -// FontAwesome -import 'admin-lte/plugins/fontawesome-free/css/fontawesome.css'; -import 'admin-lte/plugins/fontawesome-free/css/regular.css'; -import 'admin-lte/plugins/fontawesome-free/css/solid.css'; - // AdminLTE +import 'admin-lte/plugins/jquery/jquery.min.js'; +import 'admin-lte/plugins/bootstrap/js/bootstrap.bundle.min.js'; import 'admin-lte/build/scss/adminlte.scss'; -import 'admin-lte/plugins/datatables-bs4/css/dataTables.bootstrap4.css'; -import 'admin-lte/plugins/datatables-responsive/css/responsive.bootstrap4.css'; -import 'admin-lte/plugins/bootstrap/js/bootstrap.js'; import 'admin-lte/build/js/AdminLTE.js'; -import 'admin-lte/build/js/Layout.js'; -import 'admin-lte/build/js/ControlSidebar.js'; -import 'admin-lte/build/js/PushMenu.js'; + +// fontawesome plugin +import 'admin-lte/plugins/fontawesome-free/css/all.min.css'; + +// select2 plugin +import 'admin-lte/plugins/select2/css/select2.min.css'; +import 'admin-lte/plugins/select2/js/select2.min.js'; + +// dataTables plugin +import 'admin-lte/plugins/datatables-bs4/css/dataTables.bootstrap4.min.css'; +import 'admin-lte/plugins/datatables-responsive/css/responsive.bootstrap4.min.css'; +import 'admin-lte/plugins/datatables/jquery.dataTables.min.js'; +import 'admin-lte/plugins/datatables-bs4/js/dataTables.bootstrap4.min.js'; +import 'admin-lte/plugins/datatables-responsive/js/dataTables.responsive.min.js'; +import 'admin-lte/plugins/datatables-responsive/js/responsive.bootstrap4.min.js'; + +// clipboard.js +import 'clipboard/dist/clipboard.min.js'; + diff --git a/core/admin/audit.py b/core/admin/audit.py old mode 100644 new mode 100755 index db105ff4..60583f83 --- a/core/admin/audit.py +++ b/core/admin/audit.py @@ -1,14 +1,19 @@ -from mailu import app +#!/usr/bin/python3 import sys import tabulate +sys.path[0:0] = ['/app'] + +import mailu +app = mailu.create_app() + # Known endpoints without permissions known_missing_permissions = [ - "index", - "static", "bootstrap.static", - "admin.static", "admin.login" + 'index', + 'static', 'bootstrap.static', + 'admin.static', 'admin.login' ] @@ -16,7 +21,7 @@ known_missing_permissions = [ missing_permissions = [] permissions = {} for endpoint, function in app.view_functions.items(): - audit = function.__dict__.get("_audit_permissions") + audit = function.__dict__.get('_audit_permissions') if audit: handler, args = audit if args: @@ -28,16 +33,15 @@ for endpoint, function in app.view_functions.items(): elif endpoint not in known_missing_permissions: missing_permissions.append(endpoint) - -# Fail if any endpoint is missing a permission check -if missing_permissions: - print("The following endpoints are missing permission checks:") - print(missing_permissions.join(",")) - sys.exit(1) - - # Display the permissions table print(tabulate.tabulate([ [route, *permissions[route.endpoint]] for route in app.url_map.iter_rules() if route.endpoint in permissions ])) + +# Warn if any endpoint is missing a permission check +if missing_permissions: + print() + print('The following endpoints are missing permission checks:') + print(','.join(missing_permissions)) + diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py index 8ab8ed0e..fe1f376c 100644 --- a/core/admin/mailu/__init__.py +++ b/core/admin/mailu/__init__.py @@ -11,11 +11,10 @@ import hmac def create_app_from_config(config): """ Create a new application based on the given configuration """ - app = flask.Flask(__name__) + app = flask.Flask(__name__, static_folder='static', static_url_path='/static') app.cli.add_command(manage.mailu) - # Bootstrap is used for basic JS and CSS loading - # TODO: remove this and use statically generated assets instead + # Bootstrap is used for error display and flash messages app.bootstrap = flask_bootstrap.Bootstrap(app) # Initialize application extensions @@ -29,7 +28,18 @@ 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 + app.config.translations = { + str(locale): locale + for locale in sorted( + utils.babel.list_translations(), + key=lambda l: l.get_language_name().title() + ) + } # Initialize debugging tools if app.config.get("DEBUG"): @@ -43,15 +53,24 @@ def create_app_from_config(config): def inject_defaults(): signup_domains = models.Domain.query.filter_by(signup_enabled=True).all() return dict( - signup_domains=signup_domains, - config=app.config + signup_domains= signup_domains, + config = app.config, ) - # Import views - from mailu import ui, internal - app.register_blueprint(ui.ui, url_prefix='/ui') - app.register_blueprint(internal.internal, url_prefix='/internal') + # Jinja filters + @app.template_filter() + def format_date(value): + return utils.flask_babel.format_date(value) if value else '' + @app.template_filter() + def format_datetime(value): + return utils.flask_babel.format_datetime(value) if value else '' + + # Import views + from mailu import ui, internal, sso + app.register_blueprint(ui.ui, url_prefix=app.config['WEB_ADMIN']) + app.register_blueprint(internal.internal, url_prefix='/internal') + app.register_blueprint(sso.sso, url_prefix='/sso') return app @@ -60,3 +79,4 @@ def create_app(): """ config = configuration.ConfigManager() return create_app_from_config(config) + diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 7cd3a56b..b60b8a3e 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -2,6 +2,7 @@ import os from datetime import timedelta from socrate import system +import ipaddress DEFAULT_CONFIG = { # Specific to the admin UI @@ -35,8 +36,13 @@ DEFAULT_CONFIG = { 'WILDCARD_SENDERS': '', 'TLS_FLAVOR': 'cert', 'INBOUND_TLS_ENFORCE': False, - 'AUTH_RATELIMIT': '1000/minute;10000/hour', - 'AUTH_RATELIMIT_SUBNET': False, + 'DEFER_ON_TLS_ERROR': True, + '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, @@ -48,20 +54,26 @@ DEFAULT_CONFIG = { 'DKIM_PATH': '/dkim/{domain}.{selector}.key', 'DEFAULT_QUOTA': 1000000000, 'MESSAGE_RATELIMIT': '200/day', + 'MESSAGE_RATELIMIT_EXEMPTION': '', + 'RECIPIENT_DELIMITER': '', # Web settings 'SITENAME': 'Mailu', 'WEBSITE': 'https://mailu.io', + 'ADMIN' : 'none', 'WEB_ADMIN': '/admin', 'WEB_WEBMAIL': '/webmail', 'WEBMAIL': 'none', 'RECAPTCHA_PUBLIC_KEY': '', 'RECAPTCHA_PRIVATE_KEY': '', + 'LOGO_URL': None, + 'LOGO_BACKGROUND': None, # Advanced settings 'LOG_LEVEL': 'WARNING', 'SESSION_KEY_BITS': 128, 'SESSION_LIFETIME': 24, 'SESSION_COOKIE_SECURE': True, 'CREDENTIAL_ROUNDS': 12, + 'TZ': 'Etc/UTC', # Host settings 'HOST_IMAP': 'imap', 'HOST_LMTP': 'imap:2525', @@ -78,7 +90,7 @@ DEFAULT_CONFIG = { 'POD_ADDRESS_RANGE': None } -class ConfigManager(dict): +class ConfigManager: """ Naive configuration manager that uses environment only """ @@ -93,19 +105,16 @@ class ConfigManager(dict): def get_host_address(self, name): # if MYSERVICE_ADDRESS is defined, use this - if '{}_ADDRESS'.format(name) in os.environ: - return os.environ.get('{}_ADDRESS'.format(name)) + if f'{name}_ADDRESS' in os.environ: + return os.environ.get(f'{name}_ADDRESS') # otherwise use the host name and resolve it - return system.resolve_address(self.config['HOST_{}'.format(name)]) + return system.resolve_address(self.config[f'HOST_{name}']) def resolve_hosts(self): - self.config["IMAP_ADDRESS"] = self.get_host_address("IMAP") - self.config["POP3_ADDRESS"] = self.get_host_address("POP3") - self.config["AUTHSMTP_ADDRESS"] = self.get_host_address("AUTHSMTP") - self.config["SMTP_ADDRESS"] = self.get_host_address("SMTP") - self.config["REDIS_ADDRESS"] = self.get_host_address("REDIS") - if self.config["WEBMAIL"] != "none": - self.config["WEBMAIL_ADDRESS"] = self.get_host_address("WEBMAIL") + for key in ['IMAP', 'POP3', 'AUTHSMTP', 'SMTP', 'REDIS']: + self.config[f'{key}_ADDRESS'] = self.get_host_address(key) + if self.config['WEBMAIL'] != 'none': + self.config['WEBMAIL_ADDRESS'] = self.get_host_address('WEBMAIL') def __get_env(self, key, value): key_file = key + "_FILE" @@ -124,6 +133,7 @@ class ConfigManager(dict): return value def init_app(self, app): + # get current app config self.config.update(app.config) # get environment variables self.config.update({ @@ -137,31 +147,18 @@ class ConfigManager(dict): template = self.DB_TEMPLATES[self.config['DB_FLAVOR']] self.config['SQLALCHEMY_DATABASE_URI'] = template.format(**self.config) - self.config['RATELIMIT_STORAGE_URL'] = 'redis://{0}/2'.format(self.config['REDIS_ADDRESS']) - self.config['QUOTA_STORAGE_URL'] = 'redis://{0}/1'.format(self.config['REDIS_ADDRESS']) - self.config['SESSION_STORAGE_URL'] = 'redis://{0}/3'.format(self.config['REDIS_ADDRESS']) + self.config['RATELIMIT_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/2' + self.config['QUOTA_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/1' + self.config['SESSION_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/3' self.config['SESSION_COOKIE_SAMESITE'] = 'Strict' self.config['SESSION_COOKIE_HTTPONLY'] = True self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) - # update the app config itself - app.config = self + 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['MESSAGE_RATELIMIT_EXEMPTION'] = set([s for s in self.config['MESSAGE_RATELIMIT_EXEMPTION'].lower().replace(' ', '').split(',') if s]) + self.config['HOSTNAMES'] = ','.join(hostnames) + self.config['HOSTNAME'] = hostnames[0] - def setdefault(self, key, value): - if key not in self.config: - self.config[key] = value - return self.config[key] + # update the app config + app.config.update(self.config) - def get(self, *args): - return self.config.get(*args) - - def keys(self): - return self.config.keys() - - def __getitem__(self, key): - return self.config.get(key) - - def __setitem__(self, key, value): - self.config[key] = value - - def __contains__(self, key): - return key in self.config diff --git a/core/admin/mailu/debug.py b/core/admin/mailu/debug.py index 7677901b..4d63f3c5 100644 --- a/core/admin/mailu/debug.py +++ b/core/admin/mailu/debug.py @@ -1,6 +1,6 @@ import flask_debugtoolbar -from werkzeug.contrib import profiler as werkzeug_profiler +from werkzeug.middleware.profiler import ProfilerMiddleware # Debugging toolbar @@ -10,7 +10,7 @@ toolbar = flask_debugtoolbar.DebugToolbarExtension() # Profiler class Profiler(object): def init_app(self, app): - app.wsgi_app = werkzeug_profiler.ProfilerMiddleware( + app.wsgi_app = ProfilerMiddleware( app.wsgi_app, restrictions=[30] ) diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 5e60cd0c..027db935 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -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,37 +77,46 @@ def handle_authentication(headers): } # Authenticated user elif method == "plain": - server, port = get_server(headers["Auth-Protocol"], True) + is_valid_user = False # According to RFC2616 section 3.7.1 and PEP 3333, HTTP headers should # be ASCII and are generally considered ISO8859-1. However when passing # the password, nginx does not transcode the input UTF string, thus # we need to manually decode. raw_user_email = urllib.parse.unquote(headers["Auth-User"]) - user_email = raw_user_email.encode("iso8859-1").decode("utf8") raw_password = urllib.parse.unquote(headers["Auth-Pass"]) - password = raw_password.encode("iso8859-1").decode("utf8") - ip = urllib.parse.unquote(headers["Client-Ip"]) - service_port = int(urllib.parse.unquote(headers["Auth-Port"])) - if service_port == 25: - return { - "Auth-Status": "AUTH not supported", - "Auth-Error-Code": "502 5.5.1", - "Auth-Wait": 0 - } - user = models.User.query.get(user_email) - if check_credentials(user, password, ip, protocol): - return { - "Auth-Status": "OK", - "Auth-Server": server, - "Auth-Port": port - } + 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: - status, code = get_status(protocol, "authentication") - return { - "Auth-Status": status, - "Auth-Error-Code": code, - "Auth-Wait": 0 - } + 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 return {} diff --git a/core/admin/mailu/internal/templates/default.sieve b/core/admin/mailu/internal/templates/default.sieve index 5e995611..a29e7aba 100644 --- a/core/admin/mailu/internal/templates/default.sieve +++ b/core/admin/mailu/internal/templates/default.sieve @@ -19,7 +19,7 @@ if header :index 2 :matches "Received" "from * by * for <*>; *" } {% if user.spam_enabled %} -if spamtest :percent :value "gt" :comparator "i;ascii-numeric" "{{ user.spam_threshold }}" +if spamtest :percent :value "gt" :comparator "i;ascii-numeric" "{{ user.spam_threshold }}" { setflag "\\seen"; fileinto :create "Junk"; @@ -32,6 +32,6 @@ if exists "X-Virus" { stop; } -{% if user.reply_active %} +{% if user.reply_active %} vacation :days 1 {% if user.displayed_name != "" %}:from "{{ user.displayed_name }} <{{ user.email }}>"{% endif %} :subject "{{ user.reply_subject }}" "{{ user.reply_body }}"; {% endif %} diff --git a/core/admin/mailu/internal/views/__init__.py b/core/admin/mailu/internal/views/__init__.py index a32106c0..762b2a38 100644 --- a/core/admin/mailu/internal/views/__init__.py +++ b/core/admin/mailu/internal/views/__init__.py @@ -1,3 +1,3 @@ __all__ = [ - 'auth', 'postfix', 'dovecot', 'fetch' + 'auth', 'postfix', 'dovecot', 'fetch', 'rspamd' ] diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 1686e1cb..344be78b 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -5,19 +5,24 @@ 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): + headers = flask.request.headers + if headers["Auth-Port"] == '25' and headers['Auth-Method'] == 'plain': 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'] = 'AUTH not supported' + response.headers['Auth-Error-Code'] = '502 5.5.1' + utils.limiter.rate_limit_ip(client_ip) + return response + 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'] = 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 +30,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: + utils.limiter.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 +78,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 diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py index 2e7d0b9b..ed951943 100644 --- a/core/admin/mailu/internal/views/postfix.py +++ b/core/admin/mailu/internal/views/postfix.py @@ -7,6 +7,9 @@ import idna import re import srslib +@internal.route("/postfix/dane/") +def postfix_dane_map(domain_name): + return flask.jsonify('dane-only') if utils.has_dane_record(domain_name) else flask.abort(404) @internal.route("/postfix/domain/") def postfix_mailbox_domain(domain_name): @@ -105,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)) @@ -120,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) @@ -137,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) @@ -145,6 +149,8 @@ def postfix_sender_login(sender): def postfix_sender_rate(sender): """ Rate limit outbound emails per sender login """ + if sender in flask.current_app.config['MESSAGE_RATELIMIT_EXEMPTION']: + flask.abort(404) user = models.User.get(sender) or flask.abort(404) return flask.abort(404) if user.sender_limiter.hit() else flask.jsonify("450 4.2.1 You are sending too many emails too fast.") diff --git a/core/admin/mailu/internal/views/rspamd.py b/core/admin/mailu/internal/views/rspamd.py new file mode 100644 index 00000000..458dbb81 --- /dev/null +++ b/core/admin/mailu/internal/views/rspamd.py @@ -0,0 +1,27 @@ +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/", methods=['GET']) +def rspamd_dkim_key(domain_name): + selectors = [] + if domain := models.Domain.query.get(domain_name): + if key := domain.dkim_key: + selectors.append( + { + 'domain' : domain.name, + 'key' : key.decode('utf8'), + 'selector': flask.current_app.config.get('DKIM_SELECTOR', 'dkim'), + } + ) + return flask.jsonify({'data': {'selectors': selectors}}) diff --git a/core/admin/mailu/limiter.py b/core/admin/mailu/limiter.py index b5f99915..3bc65f4f 100644 --- a/core/admin/mailu/limiter.py +++ b/core/admin/mailu/limiter.py @@ -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) \ No newline at end of file + 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}' diff --git a/core/admin/mailu/manage.py b/core/admin/mailu/manage.py index 5708327e..937c9f49 100644 --- a/core/admin/mailu/manage.py +++ b/core/admin/mailu/manage.py @@ -48,44 +48,44 @@ def advertise(): @click.argument('localpart') @click.argument('domain_name') @click.argument('password') -@click.option('-m', '--mode') +@click.option('-m', '--mode', default='create', metavar='MODE', help='''\b'create' (default): create user. it's an error if user already exists +'ifmissing': only update password if user is missing +'update': create user or update password if user exists +''') @with_appcontext -def admin(localpart, domain_name, password, mode='create'): +def admin(localpart, domain_name, password, mode): """ Create an admin user - 'mode' can be: - - 'create' (default) Will try to create user and will raise an exception if present - - 'ifmissing': if user exists, nothing happens, else it will be created - - 'update': user is created or, if it exists, its password gets updated """ + + if not mode in ('create', 'update', 'ifmissing'): + raise click.ClickException(f'invalid mode: {mode!r}') + domain = models.Domain.query.get(domain_name) if not domain: domain = models.Domain(name=domain_name) db.session.add(domain) - user = None - if mode == 'ifmissing' or mode == 'update': - email = f'{localpart}@{domain_name}' - user = models.User.query.get(email) - - if user and mode == 'ifmissing': - print('user %s exists, not updating' % email) + email = f'{localpart}@{domain_name}' + if user := models.User.query.get(email): + if mode == 'ifmissing': + print(f'user {email!r} exists, not updating') return - - if not user: + elif mode == 'update': + user.set_password(password) + db.session.commit() + print("updated admin password") + else: + raise click.ClickException(f'user {email!r} exists, not created') + else: user = models.User( localpart=localpart, domain=domain, global_admin=True ) - user.set_password(password) db.session.add(user) + user.set_password(password) db.session.commit() print("created admin user") - elif mode == 'update': - user.set_password(password) - db.session.commit() - print("updated admin password") - @mailu.command() @@ -119,7 +119,7 @@ def password(localpart, domain_name, password): """ Change the password of an user """ email = f'{localpart}@{domain_name}' - user = models.User.query.get(email) + user = models.User.query.get(email) if user: user.set_password(password) else: diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index cdc5a579..aedef62a 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -19,7 +19,8 @@ import os import hmac import smtplib import idna -import dns +import dns.resolver +import dns.exception from flask import current_app as app from sqlalchemy.ext import declarative @@ -38,6 +39,8 @@ class IdnaDomain(db.TypeDecorator): """ impl = db.String(80) + cache_ok = True + python_type = str def process_bind_param(self, value, dialect): """ encode unicode domain name to punycode """ @@ -47,16 +50,18 @@ class IdnaDomain(db.TypeDecorator): """ decode punycode domain name to unicode """ return idna.decode(value) - python_type = str - class IdnaEmail(db.TypeDecorator): """ Stores a Unicode string in it's IDNA representation (ASCII only) """ impl = db.String(255) + cache_ok = True + python_type = str 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 "@"') @@ -67,13 +72,13 @@ class IdnaEmail(db.TypeDecorator): localpart, domain_name = value.rsplit('@', 1) return f'{localpart}@{idna.decode(domain_name)}' - python_type = str - class CommaSeparatedList(db.TypeDecorator): """ Stores a list as a comma-separated string, compatible with Postfix. """ impl = db.String + cache_ok = True + python_type = list def process_bind_param(self, value, dialect): """ join list of items to comma separated string """ @@ -88,13 +93,13 @@ class CommaSeparatedList(db.TypeDecorator): """ split comma separated string to list """ return list(filter(bool, (item.strip() for item in value.split(',')))) if value else [] - python_type = list - class JSONEncoded(db.TypeDecorator): """ Represents an immutable structure as a json-encoded string. """ impl = db.String + cache_ok = True + python_type = str def process_bind_param(self, value, dialect): """ encode data as json """ @@ -104,8 +109,6 @@ class JSONEncoded(db.TypeDecorator): """ decode json to data """ return json.loads(value) if value else None - python_type = str - class Base(db.Model): """ Base class for all models """ @@ -209,16 +212,16 @@ class Domain(Base): os.unlink(file_path) self._dkim_key_on_disk = self._dkim_key - @property + @cached_property def dns_mx(self): """ return MX record for domain """ - hostname = app.config['HOSTNAMES'].split(',', 1)[0] + hostname = app.config['HOSTNAME'] return f'{self.name}. 600 IN MX 10 {hostname}.' - @property + @cached_property def dns_spf(self): """ return SPF record for domain """ - hostname = app.config['HOSTNAMES'].split(',', 1)[0] + hostname = app.config['HOSTNAME'] return f'{self.name}. 600 IN TXT "v=spf1 mx a:{hostname} ~all"' @property @@ -226,12 +229,11 @@ class Domain(Base): """ return DKIM record for domain """ if self.dkim_key: selector = app.config['DKIM_SELECTOR'] - return ( - f'{selector}._domainkey.{self.name}. 600 IN TXT' - f'"v=DKIM1; k=rsa; p={self.dkim_publickey}"' - ) + txt = f'v=DKIM1; k=rsa; p={self.dkim_publickey}' + record = ' '.join(f'"{txt[p:p+250]}"' for p in range(0, len(txt), 250)) + return f'{selector}._domainkey.{self.name}. 600 IN TXT {record}' - @property + @cached_property def dns_dmarc(self): """ return DMARC record for domain """ if self.dkim_key: @@ -242,6 +244,41 @@ 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) """ + hostname = app.config['HOSTNAME'] + protocols = [ + ('submission', 587), + ('imap', 143), + ('pop3', 110), + ] + if app.config['TLS_FLAVOR'] != 'notls': + protocols.extend([ + ('imaps', 993), + ('pop3s', 995), + ]) + return list([ + f'_{proto}._tcp.{self.name}. 600 IN SRV 1 1 {port} {hostname}.' + for proto, port + in protocols + ]) + + @cached_property + def dns_tlsa(self): + """ return TLSA record for domain when using letsencrypt """ + hostname = app.config['HOSTNAME'] + if app.config['TLS_FLAVOR'] in ('letsencrypt', 'mail-letsencrypt'): + # current ISRG Root X1 (RSA 4096, O = Internet Security Research Group, CN = ISRG Root X1) @20210902 + return f'_25._tcp.{hostname}. 600 IN TLSA 2 1 1 0b9fa5a59eed715c26c1020c711b4f6ec42d58b0015e14337a39dad301c5afc3' + @property def dkim_key(self): """ return private DKIM key """ @@ -533,6 +570,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: diff --git a/core/admin/mailu/schemas.py b/core/admin/mailu/schemas.py index 191d01ac..00cbf464 100644 --- a/core/admin/mailu/schemas.py +++ b/core/admin/mailu/schemas.py @@ -145,6 +145,11 @@ class Logger: if history.has_changes() and history.deleted: before = history.deleted[-1] after = getattr(target, attr.key) + # we don't have ordered lists + if isinstance(before, list): + before = set(before) + if isinstance(after, list): + after = set(after) # TODO: this can be removed when comment is not nullable in model if attr.key == 'comment' and not before and not after: pass diff --git a/core/admin/mailu/sso/__init__.py b/core/admin/mailu/sso/__init__.py new file mode 100644 index 00000000..98b5abd0 --- /dev/null +++ b/core/admin/mailu/sso/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +sso = Blueprint('sso', __name__, static_folder=None, template_folder='templates') + +from mailu.sso.views import * diff --git a/core/admin/mailu/sso/forms.py b/core/admin/mailu/sso/forms.py new file mode 100644 index 00000000..c190b8bc --- /dev/null +++ b/core/admin/mailu/sso/forms.py @@ -0,0 +1,11 @@ +from wtforms import validators, fields +from flask_babel import lazy_gettext as _ +import flask_wtf + +class LoginForm(flask_wtf.FlaskForm): + class Meta: + csrf = False + email = fields.StringField(_('E-mail'), [validators.Email(), validators.DataRequired()]) + pw = fields.PasswordField(_('Password'), [validators.DataRequired()]) + submitAdmin = fields.SubmitField(_('Sign in')) + submitWebmail = fields.SubmitField(_('Sign in')) diff --git a/core/admin/mailu/sso/templates/base_sso.html b/core/admin/mailu/sso/templates/base_sso.html new file mode 100644 index 00000000..9dfb25a5 --- /dev/null +++ b/core/admin/mailu/sso/templates/base_sso.html @@ -0,0 +1,86 @@ +{%- import "macros.html" as macros %} +{%- import "bootstrap/utils.html" as utils %} + + + + + + + + Mailu-Admin | {{ config["SITENAME"] }} + + + + +
+ + +
+
+
+
+
+

{%- block title %}{%- endblock %}

+ {% block subtitle %}{% endblock %} +
+
+ {%- block main_action %}{%- endblock %} +
+
+
+
+
+ {{ utils.flashed_messages(container=False, default_category='success') }} + {%- block content %}{%- endblock %} +
+
+ +
+ + + + diff --git a/core/admin/mailu/sso/templates/form_sso.html b/core/admin/mailu/sso/templates/form_sso.html new file mode 100644 index 00000000..d2451597 --- /dev/null +++ b/core/admin/mailu/sso/templates/form_sso.html @@ -0,0 +1,11 @@ +{%- extends "base_sso.html" %} + +{%- block content %} +{%- call macros.card() %} +
+ {{ macros.form_field(form.email) }} + {{ macros.form_field(form.pw) }} + {{ macros.form_fields(fields, label=False, class="btn btn-default") }} +
+{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/sso/templates/login.html b/core/admin/mailu/sso/templates/login.html new file mode 100644 index 00000000..c727e01e --- /dev/null +++ b/core/admin/mailu/sso/templates/login.html @@ -0,0 +1,5 @@ +{%- extends "form_sso.html" %} + +{%- block title %} +{% trans %}Sign in{% endtrans %} +{%- endblock %} diff --git a/core/admin/mailu/sso/templates/sidebar_sso.html b/core/admin/mailu/sso/templates/sidebar_sso.html new file mode 100644 index 00000000..86db3333 --- /dev/null +++ b/core/admin/mailu/sso/templates/sidebar_sso.html @@ -0,0 +1,55 @@ + diff --git a/core/admin/mailu/sso/views/__init__.py b/core/admin/mailu/sso/views/__init__.py new file mode 100644 index 00000000..7b1830fb --- /dev/null +++ b/core/admin/mailu/sso/views/__init__.py @@ -0,0 +1,3 @@ +__all__ = [ + 'base', 'languages' +] diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py new file mode 100644 index 00000000..390d5bbf --- /dev/null +++ b/core/admin/mailu/sso/views/base.py @@ -0,0 +1,57 @@ +from werkzeug.utils import redirect +from mailu import models, utils +from mailu.sso import sso, forms +from mailu.ui import access + +from flask import current_app as app +import flask +import flask_login + +@sso.route('/login', methods=['GET', 'POST']) +def login(): + client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) + form = forms.LoginForm() + form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin' + form.submitWebmail.label.text = form.submitWebmail.label.text + ' Webmail' + + fields = [] + if str(app.config["ADMIN"]).upper() != "FALSE": + fields.append(form.submitAdmin) + if str(app.config["WEBMAIL"]).upper() != "NONE": + fields.append(form.submitWebmail) + fields = [fields] + + if form.validate_on_submit(): + if form.submitAdmin.data: + destination = app.config['WEB_ADMIN'] + elif form.submitWebmail.data: + destination = app.config['WEB_WEBMAIL'] + device_cookie, device_cookie_username = utils.limiter.parse_device_cookie(flask.request.cookies.get('rate_limit')) + username = form.email.data + if username != device_cookie_username and utils.limiter.should_rate_limit_ip(client_ip): + flask.flash('Too many attempts from your IP (rate-limit)', 'error') + return flask.render_template('login.html', form=form) + if utils.limiter.should_rate_limit_user(username, client_ip, device_cookie, device_cookie_username): + flask.flash('Too many attempts for this user (rate-limit)', 'error') + return flask.render_template('login.html', form=form) + user = models.User.login(username, form.pw.data) + if user: + flask.session.regenerate() + flask_login.login_user(user) + response = flask.redirect(destination) + response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True) + flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.') + return response + else: + utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip) + flask.current_app.logger.warn(f'Login failed for {username} from {client_ip}.') + flask.flash('Wrong e-mail or password', 'error') + return flask.render_template('login.html', form=form, fields=fields) + +@sso.route('/logout', methods=['GET']) +@access.authenticated +def logout(): + flask_login.logout_user() + flask.session.destroy() + return flask.redirect(flask.url_for('.login')) + diff --git a/core/admin/mailu/sso/views/languages.py b/core/admin/mailu/sso/views/languages.py new file mode 100644 index 00000000..66c09b1f --- /dev/null +++ b/core/admin/mailu/sso/views/languages.py @@ -0,0 +1,7 @@ +from mailu.sso import sso +import flask + +@sso.route('/language/', methods=['POST']) +def set_language(language=None): + flask.session['language'] = language + return flask.Response(status=200) diff --git a/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po b/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po index cec7a4a0..09130a7b 100644 --- a/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po @@ -1,188 +1,287 @@ msgid "" msgstr "" -"Project-Id-Version: Mailu\n" +"Project-Id-Version: Mailu\n" +"POT-Creation-Date: 2021-02-05 16:34+0100\n" "PO-Revision-Date: 2020-02-17 20:23+0000\n" -"Last-Translator: NeroPcStation \n" -"Language-Team: Polish \n" +"Last-Translator: Marcin Siennicki \n" "Language: pl\n" +"Language-Team: Polish " +"\n" +"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && " +"(n%100<10 || n%100>=20) ? 1 : 2\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" -"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " -"|| n%100>=20) ? 1 : 2;\n" -"X-Generator: Weblate 3.3\n" +"Generated-By: Babel 2.9.0\n" -#: mailu/ui/forms.py:32 +#: mailu/ui/forms.py:33 mailu/ui/forms.py:36 msgid "Invalid email address." msgstr "Nieprawidłowy adres e-mail." -#: mailu/ui/forms.py:36 +#: mailu/ui/forms.py:45 msgid "Confirm" msgstr "Zatwierdź" -#: mailu/ui/forms.py:40 mailu/ui/forms.py:77 +#: mailu/ui/forms.py:49 mailu/ui/forms.py:86 msgid "E-mail" msgstr "E-mail" -#: mailu/ui/forms.py:41 mailu/ui/forms.py:78 mailu/ui/forms.py:90 -#: mailu/ui/forms.py:109 mailu/ui/forms.py:162 +#: mailu/ui/forms.py:50 mailu/ui/forms.py:87 mailu/ui/forms.py:100 +#: mailu/ui/forms.py:118 mailu/ui/forms.py:172 #: mailu/ui/templates/client.html:32 mailu/ui/templates/client.html:59 msgid "Password" msgstr "Hasło" -#: mailu/ui/forms.py:42 mailu/ui/templates/login.html:4 -#: mailu/ui/templates/sidebar.html:111 +#: mailu/ui/forms.py:51 mailu/ui/templates/login.html:4 +#: mailu/ui/templates/sidebar.html:108 msgid "Sign in" msgstr "Zaloguj" -#: mailu/ui/forms.py:46 mailu/ui/forms.py:56 +#: mailu/ui/forms.py:55 mailu/ui/forms.py:65 #: mailu/ui/templates/domain/details.html:27 #: mailu/ui/templates/domain/list.html:18 mailu/ui/templates/relay/list.html:17 msgid "Domain name" msgstr "Nazwa domeny" -#: mailu/ui/forms.py:47 +#: mailu/ui/forms.py:56 msgid "Maximum user count" msgstr "Maksymalna liczba użytkowników" -#: mailu/ui/forms.py:48 +#: mailu/ui/forms.py:57 msgid "Maximum alias count" msgstr "Maksymalna liczba aliasów" -#. Needs more context - is that a verb or a noun? -#: 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:58 +msgid "Maximum user quota" +msgstr "Maksymalny przydział użytkownika" + +#: mailu/ui/forms.py:59 +msgid "Enable sign-up" +msgstr "Włącz rejestrację" + +#: mailu/ui/forms.py:60 mailu/ui/forms.py:81 mailu/ui/forms.py:93 +#: mailu/ui/forms.py:138 mailu/ui/forms.py:150 #: mailu/ui/templates/alias/list.html:21 mailu/ui/templates/domain/list.html:21 #: mailu/ui/templates/relay/list.html:19 mailu/ui/templates/token/list.html:19 #: mailu/ui/templates/user/list.html:23 msgid "Comment" msgstr "Komentarz" -#: mailu/ui/forms.py:52 mailu/ui/forms.py:61 mailu/ui/forms.py:66 -#: mailu/ui/forms.py:73 mailu/ui/forms.py:132 mailu/ui/forms.py:141 -msgid "Create" -msgstr "Utwórz" +#: mailu/ui/forms.py:61 mailu/ui/forms.py:75 mailu/ui/forms.py:82 +#: mailu/ui/forms.py:95 mailu/ui/forms.py:142 mailu/ui/forms.py:151 +msgid "Save" +msgstr "Zapisz" -#: mailu/ui/forms.py:59 mailu/ui/forms.py:79 mailu/ui/forms.py:91 +#: mailu/ui/forms.py:66 +msgid "Initial admin" +msgstr "Początkowy administrator" + +#: mailu/ui/forms.py:67 +msgid "Admin password" +msgstr "Hasło administratora" + +#: mailu/ui/forms.py:68 mailu/ui/forms.py:88 mailu/ui/forms.py:101 msgid "Confirm password" msgstr "Potwierdź hasło" -#: mailu/ui/forms.py:80 mailu/ui/templates/user/list.html:22 +#: mailu/ui/forms.py:70 +msgid "Create" +msgstr "Utwórz" + +#: mailu/ui/forms.py:74 +msgid "Alternative name" +msgstr "Alternatywna nazwa" + +#: mailu/ui/forms.py:79 +msgid "Relayed domain name" +msgstr "Domeny przekierowywane" + +#: mailu/ui/forms.py:80 mailu/ui/templates/relay/list.html:18 +msgid "Remote host" +msgstr "Zdalny host" + +#: mailu/ui/forms.py:89 mailu/ui/templates/user/list.html:22 #: mailu/ui/templates/user/signup_domain.html:16 msgid "Quota" msgstr "Maksymalna przestrzeń na dysku" -#: mailu/ui/forms.py:81 +#: mailu/ui/forms.py:90 msgid "Allow IMAP access" msgstr "Zezwalaj na dostęp przez protokół IMAP" -#: mailu/ui/forms.py:82 +#: mailu/ui/forms.py:91 msgid "Allow POP3 access" msgstr "Zezwalaj na dostęp przez protokół POP3" -#: mailu/ui/forms.py:85 -msgid "Save" -msgstr "Zapisz" - -#: mailu/ui/forms.py:97 +#: mailu/ui/forms.py:92 mailu/ui/forms.py:108 +#: mailu/ui/templates/user/settings.html:15 msgid "Displayed name" msgstr "Nazwa wyświetlana" -#: mailu/ui/forms.py:98 +#: mailu/ui/forms.py:94 +msgid "Enabled" +msgstr "Włączone" + +#: mailu/ui/forms.py:99 +msgid "Email address" +msgstr "Adres e-mail" + +#: mailu/ui/forms.py:102 mailu/ui/templates/sidebar.html:114 +#: mailu/ui/templates/user/signup.html:4 +#: mailu/ui/templates/user/signup_domain.html:4 +msgid "Sign up" +msgstr "Utwórz konto" + +#: mailu/ui/forms.py:109 msgid "Enable spam filter" msgstr "Włącz filtr antyspamowy" -#: mailu/ui/forms.py:80 -msgid "Spam filter threshold" -msgstr "Próg filtra antyspamowego" - -#: mailu/ui/forms.py:105 -msgid "Save settings" -msgstr "Zapisz ustawienia" - #: mailu/ui/forms.py:110 -msgid "Password check" -msgstr "" +msgid "Spam filter tolerance" +msgstr "Tolerancja filtra spamu" -#: mailu/ui/forms.py:111 mailu/ui/templates/sidebar.html:16 -msgid "Update password" -msgstr "Zaktualizuj hasło" - -#: mailu/ui/forms.py:100 +#: mailu/ui/forms.py:111 msgid "Enable forwarding" msgstr "Włącz przekierowanie poczty" -#: mailu/ui/forms.py:103 mailu/ui/forms.py:139 +#: mailu/ui/forms.py:112 +msgid "Keep a copy of the emails" +msgstr "Przechowuj kopię wiadomości" + +#: mailu/ui/forms.py:113 mailu/ui/forms.py:149 #: mailu/ui/templates/alias/list.html:20 msgid "Destination" msgstr "Adres docelowy" -#: mailu/ui/forms.py:120 -msgid "Update" -msgstr "Aktualizuj" +#: mailu/ui/forms.py:114 +msgid "Save settings" +msgstr "Zapisz ustawienia" -#: mailu/ui/forms.py:115 +#: mailu/ui/forms.py:119 +msgid "Password check" +msgstr "Powtórz hasło" + +#: mailu/ui/forms.py:120 mailu/ui/templates/sidebar.html:16 +msgid "Update password" +msgstr "Zaktualizuj hasło" + +#: mailu/ui/forms.py:124 msgid "Enable automatic reply" msgstr "Włącz automatyczną odpowiedź" -#: mailu/ui/forms.py:116 +#: mailu/ui/forms.py:125 msgid "Reply subject" msgstr "Temat odpowiedzi" -#: mailu/ui/forms.py:117 +#: mailu/ui/forms.py:126 msgid "Reply body" msgstr "Treść odpowiedzi" -#: mailu/ui/forms.py:136 +#: mailu/ui/forms.py:128 +#, fuzzy +msgid "Start of vacation" +msgstr "Rozpoczęcie nieobecności" + +#: mailu/ui/forms.py:129 +msgid "End of vacation" +msgstr "Koniec nieobecności" + +#: mailu/ui/forms.py:130 +msgid "Update" +msgstr "Aktualizuj" + +#: mailu/ui/forms.py:135 +msgid "Your token (write it down, as it will never be displayed again)" +msgstr "Twój token (zapisz go, ponieważ nigdy więcej nie będzie wyświetlany)" + +#: mailu/ui/forms.py:140 mailu/ui/templates/token/list.html:20 +msgid "Authorized IP" +msgstr "Autoryzowany adres IP" + +#: mailu/ui/forms.py:146 msgid "Alias" msgstr "Alias" -#: mailu/ui/forms.py:138 +#: mailu/ui/forms.py:148 msgid "Use SQL LIKE Syntax (e.g. for catch-all aliases)" msgstr "Używaj składni SQL LIKE (np. do adresów catch-all)" -#: mailu/ui/forms.py:145 +#: mailu/ui/forms.py:155 msgid "Admin email" msgstr "E-mail administratora" -#: mailu/ui/forms.py:146 mailu/ui/forms.py:151 mailu/ui/forms.py:164 +#: mailu/ui/forms.py:156 mailu/ui/forms.py:161 mailu/ui/forms.py:174 msgid "Submit" msgstr "Prześlij" -#: mailu/ui/forms.py:150 +#: mailu/ui/forms.py:160 msgid "Manager email" msgstr "E-mail menedżera" -#: mailu/ui/forms.py:155 +#: mailu/ui/forms.py:165 msgid "Protocol" msgstr "Protokół" -#: mailu/ui/forms.py:158 +#: mailu/ui/forms.py:168 msgid "Hostname or IP" msgstr "Nazwa hosta lub adres IP" -#: mailu/ui/forms.py:159 mailu/ui/templates/client.html:20 +#: mailu/ui/forms.py:169 mailu/ui/templates/client.html:20 #: mailu/ui/templates/client.html:47 msgid "TCP port" msgstr "Port TCP" -#: mailu/ui/forms.py:160 +#: mailu/ui/forms.py:170 msgid "Enable TLS" msgstr "Włącz TLS" -#: mailu/ui/forms.py:161 mailu/ui/templates/client.html:28 +#: mailu/ui/forms.py:171 mailu/ui/templates/client.html:28 #: mailu/ui/templates/client.html:55 mailu/ui/templates/fetch/list.html:20 msgid "Username" msgstr "Nazwa użytkownika" +#: mailu/ui/forms.py:173 +msgid "Keep emails on the server" +msgstr "Przechowuj wiadomości na serwerze" + +#: mailu/ui/forms.py:178 +msgid "Announcement subject" +msgstr "Temat ogłoszenia" + +#: mailu/ui/forms.py:180 +msgid "Announcement body" +msgstr "Treść ogłoszenia" + +#: mailu/ui/forms.py:182 +msgid "Send" +msgstr "Wyślij" + +#: mailu/ui/templates/announcement.html:4 +msgid "Public announcement" +msgstr "Publiczne ogłoszenie" + +#: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:79 +msgid "Client setup" +msgstr "Konfiguracja klienta" + +#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43 +msgid "Mail protocol" +msgstr "Protokół poczty" + +#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51 +msgid "Server name" +msgstr "Nazwa serwera" + #: mailu/ui/templates/confirm.html:4 msgid "Confirm action" msgstr "Potwierdź wykonanie czynności" #: mailu/ui/templates/confirm.html:13 +#, python-format msgid "You are about to %(action)s. Please confirm your action." -msgstr "Zamierzasz wykonać następujące czynności: %(action)s. Potwierdź wykonanie czynności." +msgstr "" +"Zamierzasz wykonać następujące czynności: %(action)s. Potwierdź wykonanie" +" czynności." #: mailu/ui/templates/docker-error.html:4 msgid "Docker error" @@ -192,54 +291,19 @@ msgstr "Błąd Dockera" msgid "An error occurred while talking to the Docker server." msgstr "Wystąpił błąd komunikacji z serwerem Dockera." -#: mailu/admin/templates/login.html:6 -msgid "Your account" -msgstr "Twoje konto" - #: mailu/ui/templates/login.html:8 msgid "to access the administration tools" msgstr "aby uzyskać dostęp do narzędzi administracyjnych" -#: mailu/ui/templates/services.html:4 mailu/ui/templates/sidebar.html:39 -msgid "Services status" -msgstr "Status usług" - -#: mailu/ui/templates/services.html:10 -msgid "Service" -msgstr "Usługa" - -#: mailu/ui/templates/fetch/list.html:23 mailu/ui/templates/services.html:11 -msgid "Status" -msgstr "Status" - -#: mailu/ui/templates/services.html:12 -msgid "PID" -msgstr "PID" - -#: mailu/ui/templates/services.html:13 -msgid "Image" -msgstr "Obraz" - -#: mailu/ui/templates/services.html:14 -msgid "Started" -msgstr "" - -#: mailu/ui/templates/services.html:15 -msgid "Last update" -msgstr "Ostatnia aktualizacja" - #: mailu/ui/templates/sidebar.html:8 +#, fuzzy msgid "My account" -msgstr "Moje konto" +msgstr "Dodaj konto" #: mailu/ui/templates/sidebar.html:11 mailu/ui/templates/user/list.html:34 msgid "Settings" msgstr "Ustawienia" -#: mailu/ui/templates/user/settings.html:22 -msgid "Auto-forward" -msgstr "Automatyczne przekierowanie" - #: mailu/ui/templates/sidebar.html:21 mailu/ui/templates/user/list.html:35 msgid "Auto-reply" msgstr "Automatyczna odpowiedź" @@ -247,28 +311,60 @@ msgstr "Automatyczna odpowiedź" #: 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 "Zewnętrzne konta e-mail" -#: mailu/ui/templates/sidebar.html:105 -msgid "Sign out" -msgstr "Wyloguj" +#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4 +msgid "Authentication tokens" +msgstr "Tokeny uwierzytelnienia" -#: mailu/ui/templates/sidebar.html:35 +#: mailu/ui/templates/sidebar.html:36 msgid "Administration" msgstr "Administracja" -#: mailu/ui/templates/sidebar.html:49 +#: mailu/ui/templates/sidebar.html:41 +msgid "Announcement" +msgstr "Ogłoszenie" + +#: mailu/ui/templates/sidebar.html:46 msgid "Administrators" msgstr "Administratorzy" -#: mailu/ui/templates/sidebar.html:66 +#: mailu/ui/templates/sidebar.html:51 +msgid "Relayed domains" +msgstr "Domeny przekierowywane" + +#: mailu/ui/templates/sidebar.html:56 mailu/ui/templates/user/settings.html:19 +msgid "Antispam" +msgstr "Filtr antyspamowy" + +#: mailu/ui/templates/sidebar.html:63 msgid "Mail domains" msgstr "Domeny pocztowe" -#: mailu/ui/templates/sidebar.html:92 +#: mailu/ui/templates/sidebar.html:69 +msgid "Go to" +msgstr "Przejdź do" + +#: mailu/ui/templates/sidebar.html:73 +msgid "Webmail" +msgstr "Twoja poczta" + +#: mailu/ui/templates/sidebar.html:84 +msgid "Website" +msgstr "Strona internetowa" + +#: mailu/ui/templates/sidebar.html:89 msgid "Help" msgstr "Pomoc" +#: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:95 +msgid "Register a domain" +msgstr "Zarejestruj domenę" + +#: mailu/ui/templates/sidebar.html:102 +msgid "Sign out" +msgstr "Wyloguj" + #: mailu/ui/templates/working.html:4 msgid "We are still working on this feature!" msgstr "Nadal pracujemy nad tą funkcją!" @@ -344,6 +440,22 @@ msgstr "Ostatnia edycja" msgid "Edit" msgstr "Edytuj" +#: mailu/ui/templates/alternative/create.html:4 +msgid "Create alternative domain" +msgstr "Utwórz alternatywną domenę" + +#: mailu/ui/templates/alternative/list.html:4 +msgid "Alternative domain list" +msgstr "Alternatywna lista domen" + +#: mailu/ui/templates/alternative/list.html:12 +msgid "Add alternative" +msgstr "Dodaj alternatywę" + +#: mailu/ui/templates/alternative/list.html:19 +msgid "Name" +msgstr "Nazwa" + #: mailu/ui/templates/domain/create.html:4 #: mailu/ui/templates/domain/list.html:9 msgid "New domain" @@ -357,6 +469,10 @@ msgstr "Szczegóły domeny" msgid "Regenerate keys" msgstr "Wygeneruj ponownie klucze" +#: mailu/ui/templates/domain/details.html:17 +msgid "Generate keys" +msgstr "Wygeneruj klucze" + #: mailu/ui/templates/domain/details.html:31 msgid "DNS MX entry" msgstr "Wpis MX DNS" @@ -365,15 +481,15 @@ msgstr "Wpis MX DNS" msgid "DNS SPF entries" msgstr "Wpisy SPF DNS" -#: mailu/ui/templates/domain/details.html:42 +#: mailu/ui/templates/domain/details.html:41 msgid "DKIM public key" msgstr "Publiczny klucz DKIM" -#: mailu/ui/templates/domain/details.html:46 +#: mailu/ui/templates/domain/details.html:45 msgid "DNS DKIM entry" msgstr "Wpis DKIM DNS" -#: mailu/ui/templates/domain/details.html:50 +#: mailu/ui/templates/domain/details.html:49 msgid "DNS DMARC entry" msgstr "Wpis DMARC DNS" @@ -413,13 +529,42 @@ msgstr "Aliasy" msgid "Managers" msgstr "Menedżerowie" +#: mailu/ui/templates/domain/list.html:39 +msgid "Alternatives" +msgstr "Alternatywy" + +#: 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 MX points to this server" +msgstr "" +"Aby zarejestrować nową domenę, musisz najpierw skonfigurować strefę " +"domeny, aby domena MX wskazywała na ten serwer" + +#: mailu/ui/templates/domain/signup.html:18 +msgid "" +"If you do not know how to setup an MX record for your DNS " +"zone,\n" +" please contact your DNS provider or administrator. Also, please wait " +"a\n" +" couple minutes after the MX is set so the local server " +"cache\n" +" expires." +msgstr "" +"Jeśli nie wiesz, jak skonfigurować rekord MX dla swojej " +"strefy DNS,\n" +"skontaktuj się z dostawcą DNS lub administratorem. Proszę również " +"poczekać\n" +"kilka minut po ustawieniu MX , żeby pamięć podręczna " +"serwera lokalnego wygasła." + #: mailu/ui/templates/fetch/create.html:4 msgid "Add a fetched account" -msgstr "" +msgstr "Dodaj zewnętrzne konto pocztowe" #: mailu/ui/templates/fetch/edit.html:4 msgid "Update a fetched account" -msgstr "" +msgstr "Zaktualizuj konto" #: mailu/ui/templates/fetch/list.html:12 msgid "Add an account" @@ -427,12 +572,28 @@ msgstr "Dodaj konto" #: mailu/ui/templates/fetch/list.html:19 msgid "Endpoint" -msgstr "" +msgstr "Serwer" + +#: mailu/ui/templates/fetch/list.html:21 +msgid "Keep emails" +msgstr "Przechowuj wiadomości" #: mailu/ui/templates/fetch/list.html:22 msgid "Last check" msgstr "Ostatnie sprawdzenie" +#: mailu/ui/templates/fetch/list.html:23 +msgid "Status" +msgstr "Stan" + +#: mailu/ui/templates/fetch/list.html:35 +msgid "yes" +msgstr "Tak" + +#: mailu/ui/templates/fetch/list.html:35 +msgid "no" +msgstr "Nie" + #: mailu/ui/templates/manager/create.html:4 msgid "Add a manager" msgstr "Dodaj menedżera" @@ -445,34 +606,43 @@ msgstr "Lista menedżerów" msgid "Add manager" msgstr "Dodaj menedżera" -#: mailu/ui/forms.py:168 -msgid "Announcement subject" -msgstr "Temat ogłoszenia" +#: mailu/ui/templates/relay/create.html:4 +msgid "New relay domain" +msgstr "Nowa domena do przekierowania" -#: mailu/ui/forms.py:170 -msgid "Announcement body" -msgstr "Treść ogłoszenia" +#: mailu/ui/templates/relay/edit.html:4 +#, fuzzy +msgid "Edit relayd domain" +msgstr "Edycja domeny" -#: mailu/ui/forms.py:172 -msgid "Send" -msgstr "Wyślij" +#: mailu/ui/templates/relay/list.html:4 +msgid "Relayed domain list" +msgstr "Lista domen przekierowywanych" -#: mailu/ui/templates/announcement.html:4 -msgid "Public announcement" -msgstr "Publiczne ogłoszenie" +#: mailu/ui/templates/relay/list.html:9 +msgid "New relayed domain" +msgstr "Nowa domena do przekierowania" -#: mailu/ui/templates/announcement.html:8 -msgid "from" -msgstr "od" +#: mailu/ui/templates/token/create.html:4 +msgid "Create an authentication token" +msgstr "Utwórz token uwierzytelniający" -#: mailu/ui/templates/sidebar.html:44 -msgid "Announcement" -msgstr "Ogłoszenie" +#: mailu/ui/templates/token/list.html:12 +msgid "New token" +msgstr "Nowy token" #: mailu/ui/templates/user/create.html:4 msgid "New user" msgstr "Nowy użytkownik" +#: mailu/ui/templates/user/create.html:15 +msgid "General" +msgstr "Ogólne" + +#: mailu/ui/templates/user/create.html:23 +msgid "Features and quotas" +msgstr "Funkcje i limity" + #: mailu/ui/templates/user/edit.html:4 msgid "Edit user" msgstr "Edytuj użytkownika" @@ -505,202 +675,9 @@ msgstr "Zmiana hasła" msgid "Automatic reply" msgstr "Automatyczna odpowiedź" -#: mailu/ui/forms.py:49 -msgid "Maximum user quota" -msgstr "Maksymalny przydział użytkownika" - -#: mailu/ui/forms.py:101 -msgid "Keep a copy of the emails" -msgstr "Przechowuj kopię wiadomości" - -#: mailu/ui/forms.py:163 -msgid "Keep emails on the server" -msgstr "Przechowuj wiadomości na serwerze" - -#: mailu/ui/templates/fetch/list.html:21 -msgid "Keep emails" -msgstr "Przechowuj wiadomości" - -#: mailu/ui/templates/fetch/list.html:35 -msgid "yes" -msgstr "Tak" - -#: mailu/ui/templates/fetch/list.html:35 -msgid "no" -msgstr "Nie" - -#: mailu/ui/forms.py:65 -msgid "Alternative name" -msgstr "Alternatywna nazwa" - -#: 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 "Zdalny host" - -#: mailu/ui/templates/sidebar.html:54 -msgid "Relayed domains" -msgstr "" - -#: mailu/ui/templates/alternative/create.html:4 -msgid "Create alternative domain" -msgstr "Utwórz alternatywną domenę" - -#: mailu/ui/templates/alternative/list.html:4 -msgid "Alternative domain list" -msgstr "Alternatywna lista domen" - -#: mailu/ui/templates/alternative/list.html:12 -msgid "Add alternative" -msgstr "Dodaj alternatywę" - -#: mailu/ui/templates/alternative/list.html:19 -msgid "Name" -msgstr "Nazwa" - -#: mailu/ui/templates/domain/list.html:39 -msgid "Alternatives" -msgstr "Alternatywy" - -#: 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 "Twój token (zapisz go, ponieważ nigdy więcej nie będzie wyświetlany)" - -#: mailu/ui/forms.py:130 mailu/ui/templates/token/list.html:20 -msgid "Authorized IP" -msgstr "Autoryzowany adres IP" - -#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4 -msgid "Authentication tokens" -msgstr "Tokeny uwierzytelnienia" - -#: mailu/ui/templates/sidebar.html:72 -msgid "Go to" -msgstr "Przejdź do" - -#: mailu/ui/templates/sidebar.html:76 -msgid "Webmail" -msgstr "" - -#: mailu/ui/templates/sidebar.html:87 -msgid "Website" -msgstr "Strona internetowa" - -#: mailu/ui/templates/token/create.html:4 -msgid "Create an authentication token" -msgstr "Utwórz token uwierzytelniający" - -#: mailu/ui/templates/token/list.html:12 -msgid "New token" -msgstr "Nowy token" - -#: 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 "Ustawienia ogólne" - -#: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15 -msgid "Antispam" -msgstr "Filtr antyspamowy" - -#: mailu/ui/forms.py:99 -msgid "Spam filter tolerance" -msgstr "Tolerancja filtra spamu" - -#: mailu/ui/forms.py:50 -msgid "Enable sign-up" -msgstr "Włącz rejestrację" - -#: mailu/ui/forms.py:57 -msgid "Initial admin" -msgstr "Początkowy administrator" - -#: mailu/ui/forms.py:58 -msgid "Admin password" -msgstr "hasło administratora" - -#: mailu/ui/forms.py:84 -msgid "Enabled" -msgstr "Włączone" - -#: mailu/ui/forms.py:89 -msgid "Email address" -msgstr "Adres e-mail" - -#: 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 "Koniec wakacji" - -#: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:82 -msgid "Client setup" -msgstr "Konfiguracja klienta" - -#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43 -msgid "Mail protocol" -msgstr "Protokół poczty" - -#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51 -msgid "Server name" -msgstr "Nazwa serwera" - -#: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:98 -msgid "Register a domain" -msgstr "Zarejestruj domenę" - -#: mailu/ui/templates/domain/details.html:17 -msgid "Generate keys" -msgstr "Wygeneruj klucze" - -#: 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 MX points to this server" -msgstr "" -"Aby zarejestrować nową domenę, musisz najpierw skonfigurować strefę domeny, " -"aby domena MX wskazywała na ten serwer" - -#: mailu/ui/templates/domain/signup.html:18 -msgid "If you do not know how to setup an MX record for your DNS zone,\n" -" please contact your DNS provider or administrator. Also, please wait a\n" -" couple minutes after the MX is set so the local server cache\n" -" expires." -msgstr "" -"Jeśli nie wiesz, jak skonfigurować rekord MX dla swojej " -"strefy DNS,\n" -"skontaktuj się z dostawcą DNS lub administratorem. Proszę również poczekać\n" -"kilka minut po ustawieniu MX , żeby pamięć podręczna serwera " -"lokalnego wygasła." +#: mailu/ui/templates/user/settings.html:26 +msgid "Auto-forward" +msgstr "Automatyczne przekierowanie" #: mailu/ui/templates/user/signup_domain.html:8 msgid "pick a domain for the new account" @@ -713,3 +690,40 @@ msgstr "Domena" #: mailu/ui/templates/user/signup_domain.html:15 msgid "Available slots" msgstr "Dostępne miejsca" + +#~ msgid "Spam filter threshold" +#~ msgstr "Próg filtra antyspamowego" + +#~ msgid "Your account" +#~ msgstr "Twoje konto" + +#~ msgid "Services status" +#~ msgstr "Status usług" + +#~ msgid "Service" +#~ msgstr "Usługa" + +#~ msgid "Status" +#~ msgstr "Status" + +#~ msgid "PID" +#~ msgstr "PID" + +#~ msgid "Image" +#~ msgstr "Obraz" + +#~ msgid "Started" +#~ msgstr "Uruchomione" + +#~ msgid "Last update" +#~ msgstr "Ostatnia aktualizacja" + +#~ msgid "My account" +#~ msgstr "Moje konto" + +#~ msgid "from" +#~ msgstr "od" + +#~ msgid "General settings" +#~ msgstr "Ustawienia ogólne" + diff --git a/core/admin/mailu/translations/zh_CN/LC_MESSAGES/messages.po b/core/admin/mailu/translations/zh/LC_MESSAGES/messages.po similarity index 89% rename from core/admin/mailu/translations/zh_CN/LC_MESSAGES/messages.po rename to core/admin/mailu/translations/zh/LC_MESSAGES/messages.po index ee204fec..5543c5e8 100644 --- a/core/admin/mailu/translations/zh_CN/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/zh/LC_MESSAGES/messages.po @@ -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 \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 MX points to this server" +msgstr "在注册一个新的域名前,您必须先为该域名设置 MX 记录,并使其指向本服务器" + +#: mailu/ui/templates/domain/signup.html:18 +msgid "" +"If you do not know how to setup an MX record for your DNS " +"zone,\n" +" please contact your DNS provider or administrator. Also, please wait " +"a\n" +" couple minutes after the MX is set so the local server " +"cache\n" +" expires." +msgstr "如果您不知道如何为域名设置 MX 记录,请联系你的DNS提供商或者系统管理员。在设置完成 MX 记录后,请等待本地域名服务器的缓存过期。" + + #: 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 MX points to this server" -msgstr "在注册一个新的域名前,您必须先为该域名设置 MX 记录,并使其指向本服务器" - -#: mailu/ui/templates/domain/signup.html:18 -msgid "If you do not know how to setup an MX record for your DNS zone,\n" -" please contact your DNS provider or administrator. Also, please wait a\n" -" couple minutes after the MX is set so the local server cache\n" -" expires." -msgstr "如果您不知道如何为域名设置 MX 记录,请联系你的DNS提供商或者系统管理员。在设置完成 MX 记录后,请等待本地域名服务器的缓存过期。" +#: 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 "" diff --git a/core/admin/mailu/ui/__init__.py b/core/admin/mailu/ui/__init__.py index ec3601a1..49338cd1 100644 --- a/core/admin/mailu/ui/__init__.py +++ b/core/admin/mailu/ui/__init__.py @@ -1,6 +1,6 @@ from flask import Blueprint -ui = Blueprint('ui', __name__, static_folder='static', template_folder='templates') +ui = Blueprint('ui', __name__, static_folder=None, template_folder='templates') from mailu.ui.views import * diff --git a/core/admin/mailu/ui/forms.py b/core/admin/mailu/ui/forms.py index 32bb31ab..24d6f899 100644 --- a/core/admin/mailu/ui/forms.py +++ b/core/admin/mailu/ui/forms.py @@ -44,15 +44,6 @@ class MultipleEmailAddressesVerify(object): class ConfirmationForm(flask_wtf.FlaskForm): submit = fields.SubmitField(_('Confirm')) - -class LoginForm(flask_wtf.FlaskForm): - class Meta: - csrf = False - email = fields.StringField(_('E-mail'), [validators.Email()]) - pw = fields.PasswordField(_('Password'), [validators.DataRequired()]) - submit = fields.SubmitField(_('Sign in')) - - class DomainForm(flask_wtf.FlaskForm): name = fields.StringField(_('Domain name'), [validators.DataRequired()]) max_users = fields_.IntegerField(_('Maximum user count'), [validators.NumberRange(min=-1)], default=10) @@ -88,7 +79,7 @@ class UserForm(flask_wtf.FlaskForm): localpart = fields.StringField(_('E-mail'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)]) pw = fields.PasswordField(_('Password')) pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')]) - quota_bytes = fields_.IntegerSliderField(_('Quota'), default=1000000000) + quota_bytes = fields_.IntegerSliderField(_('Quota'), default=10**9) enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True) enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True) displayed_name = fields.StringField(_('Displayed name')) diff --git a/core/admin/mailu/ui/templates/admin/create.html b/core/admin/mailu/ui/templates/admin/create.html index 6c2413bc..071cb77f 100644 --- a/core/admin/mailu/ui/templates/admin/create.html +++ b/core/admin/mailu/ui/templates/admin/create.html @@ -1,15 +1,15 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Add a global administrator{% endtrans %} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.card() %} +{%- block content %} +{%- call macros.card() %}
{{ form.hidden_tag() }} {{ macros.form_field(form.admin, class_='mailselect') }} {{ macros.form_field(form.submit) }}
-{% endcall %} -{% endblock %} +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/admin/list.html b/core/admin/mailu/ui/templates/admin/list.html index f2f5d229..84d954a0 100644 --- a/core/admin/mailu/ui/templates/admin/list.html +++ b/core/admin/mailu/ui/templates/admin/list.html @@ -1,17 +1,17 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Global administrators{% endtrans %} -{% endblock %} +{%- endblock %} -{% block main_action %} +{%- block main_action %} {% trans %}Add administrator{% endtrans %} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.table() %} +{%- block content %} +{%- call macros.table() %} {% trans %}Actions{% endtrans %} @@ -19,14 +19,14 @@ - {% for admin in admins %} + {%- for admin in admins %} {{ admin }} - {% endfor %} + {%- endfor %} -{% endcall %} -{% endblock %} +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alias/create.html b/core/admin/mailu/ui/templates/alias/create.html index 2079d191..ce9f8167 100644 --- a/core/admin/mailu/ui/templates/alias/create.html +++ b/core/admin/mailu/ui/templates/alias/create.html @@ -1,15 +1,15 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Create alias{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ domain }} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.card() %} +{%- block content %} +{%- call macros.card() %}
{{ form.hidden_tag() }} {{ macros.form_field(form.localpart, append='@'+domain.name+'') }} @@ -18,5 +18,5 @@ {{ macros.form_field(form.comment) }} {{ macros.form_field(form.submit) }}
-{% endcall %} -{% endblock %} +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alias/edit.html b/core/admin/mailu/ui/templates/alias/edit.html index b28ea170..4dc13cce 100644 --- a/core/admin/mailu/ui/templates/alias/edit.html +++ b/core/admin/mailu/ui/templates/alias/edit.html @@ -1,9 +1,9 @@ -{% extends "alias/create.html" %} +{%- extends "alias/create.html" %} -{% block title %} +{%- block title %} {% trans %}Edit alias{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ alias }} -{% endblock %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alias/list.html b/core/admin/mailu/ui/templates/alias/list.html index e8ddc862..6b52165e 100644 --- a/core/admin/mailu/ui/templates/alias/list.html +++ b/core/admin/mailu/ui/templates/alias/list.html @@ -1,19 +1,19 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Alias list{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ domain.name }} -{% endblock %} +{%- endblock %} -{% block main_action %} +{%- block main_action %} {% trans %}Add alias{% endtrans %} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.table() %} +{%- block content %} +{%- call macros.table() %} {% trans %}Actions{% endtrans %} @@ -25,7 +25,7 @@ - {% for alias in domain.aliases %} + {%- for alias in domain.aliases %}   @@ -34,10 +34,10 @@ {{ alias }} {{ alias.destination|join(', ') or '-' }} {{ alias.comment or '' }} - {{ alias.created_at }} - {{ alias.updated_at or '' }} + {{ alias.created_at | format_date }} + {{ alias.updated_at | format_date }} - {% endfor %} + {%- endfor %} -{% endcall %} -{% endblock %} +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alternative/create.html b/core/admin/mailu/ui/templates/alternative/create.html index 75461c67..f10cb718 100644 --- a/core/admin/mailu/ui/templates/alternative/create.html +++ b/core/admin/mailu/ui/templates/alternative/create.html @@ -1,9 +1,9 @@ -{% extends "form.html" %} +{%- extends "form.html" %} -{% block title %} +{%- block title %} {% trans %}Create alternative domain{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ domain }} -{% endblock %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alternative/list.html b/core/admin/mailu/ui/templates/alternative/list.html index f123eb9f..4ca9f3c8 100644 --- a/core/admin/mailu/ui/templates/alternative/list.html +++ b/core/admin/mailu/ui/templates/alternative/list.html @@ -1,36 +1,38 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Alternative domain list{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ domain.name }} -{% endblock %} +{%- endblock %} -{% block main_action %} +{%- block main_action %} {% trans %}Add alternative{% endtrans %} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.table() %} +{%- block content %} +{%- call macros.table() %} {% trans %}Actions{% endtrans %} {% trans %}Name{% endtrans %} {% trans %}Created{% endtrans %} + {% trans %}Last edit{% endtrans %} - {% for alternative in domain.alternatives %} + {%- for alternative in domain.alternatives %} {{ alternative }} - {{ alternative.created_at }} + {{ alternative.created_at | format_date }} + {{ alternative.updated_at | format_date }} - {% endfor %} + {%- endfor %} -{% endcall %} -{% endblock %} +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/announcement.html b/core/admin/mailu/ui/templates/announcement.html index acdbde1a..ed7fe772 100644 --- a/core/admin/mailu/ui/templates/announcement.html +++ b/core/admin/mailu/ui/templates/announcement.html @@ -1,16 +1,16 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Public announcement{% endtrans %} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.card() %} +{%- block content %} +{%- call macros.card() %}
{{ form.hidden_tag() }} {{ macros.form_field(form.announcement_subject) }} {{ macros.form_field(form.announcement_body, rows=10) }} {{ macros.form_field(form.submit) }}
-{% endcall %} -{% endblock %} +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/antispam.html b/core/admin/mailu/ui/templates/antispam.html new file mode 100644 index 00000000..0b2713b9 --- /dev/null +++ b/core/admin/mailu/ui/templates/antispam.html @@ -0,0 +1,15 @@ +{%- extends "base.html" %} + +{%- block title %} +{% trans %}Antispam{% endtrans %} +{%- endblock %} + +{%- block subtitle %} +{% trans %}RSPAMD status page{% endtrans %} +{%- endblock %} + +{%- block content %} +
+ +
+{%- endblock %} diff --git a/core/admin/mailu/ui/templates/base.html b/core/admin/mailu/ui/templates/base.html index 89695e50..e646e579 100644 --- a/core/admin/mailu/ui/templates/base.html +++ b/core/admin/mailu/ui/templates/base.html @@ -1,68 +1,86 @@ -{% import "macros.html" as macros %} -{% import "bootstrap/utils.html" as utils %} +{%- import "macros.html" as macros %} +{%- import "bootstrap/utils.html" as utils %} - + - - - - Mailu-Admin - {{ config["SITENAME"] }} + + + + + Mailu-Admin | {{ config["SITENAME"] }} + +
-