Merge remote-tracking branch 'upstream/master' into passlib

master
Alexander Graf 3 years ago
commit aa1d605665
No known key found for this signature in database
GPG Key ID: B8A9DC143E075629

@ -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"

@ -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.

@ -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

@ -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

@ -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;
}

@ -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);
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

@ -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';

@ -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))

@ -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)

@ -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

@ -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]
)

@ -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 {}

@ -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 %}

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

@ -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

@ -7,6 +7,9 @@ import idna
import re
import srslib
@internal.route("/postfix/dane/<domain_name>")
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/<domain_name>")
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.")

@ -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/<domain_name>", 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}})

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

@ -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:

@ -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:

@ -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

@ -0,0 +1,5 @@
from flask import Blueprint
sso = Blueprint('sso', __name__, static_folder=None, template_folder='templates')
from mailu.sso.views import *

@ -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'))

@ -0,0 +1,86 @@
{%- import "macros.html" as macros %}
{%- import "bootstrap/utils.html" as utils %}
<!doctype html>
<html lang="{{ session['language'] }}" data-static="/static/">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{% trans %}Admin page for{% endtrans %} {{ config["SITENAME"] }}">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Mailu-Admin | {{ config["SITENAME"] }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
</head>
<body class="hold-transition sidebar-mini layout-fixed">
<div class="wrapper">
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars" title="{% trans %}toggle sidebar{% endtrans %}" aria-expanded="false"></i><span class="sr-only">{% trans %}toggle sidebar{% endtrans %}</span></a>
</li>
<li class="nav-item">
{%- for page, url in path %}
{%- if loop.index > 1 %}
<i class="fas fa-greater-than text-xs text-gray" aria-hidden="true"></i>
{%- endif %}
{%- if url %}
<a class="nav-link d-inline-block" href="{{ url }}" role="button">{{ page }}</a>
{%- else %}
<span class="nav-link d-inline-block">{{ page }}</span>
{%- endif %}
{%- endfor %}
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item dropdown">
<a class="nav-link" data-toggle="dropdown" href="#" aria-expanded="false">
<i class="fas fa-language text-xl" aria-hidden="true" title="{% trans %}change language{% endtrans %}"></i><span class="sr-only">Language</span>
<span class="badge badge-primary navbar-badge">{{ session['language'] }}</span></a>
<div class="dropdown-menu dropdown-menu-right p-0" id="mailu-languages">
{%- for locale in config.translations.values() %}
<a class="dropdown-item{% if locale|string() == session['language'] %} active{% endif %}" href="{{ url_for('sso.set_language', language=locale) }}">{{ locale.get_language_name().title() }}</a>
{%- endfor %}
</div>
</li>
</ul>
</nav>
<aside class="main-sidebar sidebar-dark-primary nav-compact elevation-4">
<a class="brand-link bg-mailu-logo"{% if config["LOGO_BACKGROUND"] %} style="background-color:{{ config["LOGO_BACKGROUND"] }}!important;"{% endif %}>
<img src="{{ config["LOGO_URL"] if config["LOGO_URL"] else '/static/mailu.png' }}" width="33" height="33" alt="Mailu" class="brand-image mailu-logo img-circle elevation-3">
<span class="brand-text font-weight-light">{{ config["SITENAME"] }}</span>
</a>
{%- include "sidebar_sso.html" %}
</aside>
<div class="content-wrapper text-sm">
<section class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0">{%- block title %}{%- endblock %}</h1>
<small>{% block subtitle %}{% endblock %}</small>
</div>
<div class="col-sm-6">
{%- block main_action %}{%- endblock %}
</div>
</div>
</div>
</section>
<div class="content">
{{ utils.flashed_messages(container=False, default_category='success') }}
{%- block content %}{%- endblock %}
</div>
</div>
<footer class="main-footer">
Built with <i class="fa fa-heart text-danger" aria-hidden="true"></i><span class="sr-only">love</span>
using <a href="https://flask.palletsprojects.com/">Flask</a>
and <a href="https://adminlte.io/themes/v3/index3.html">AdminLTE</a>.
<span class="fa-pull-right">
<i class="fa fa-code-branch" aria-hidden="true"></i><span class="sr-only">fork</span>
on <a href="https://github.com/Mailu/Mailu">Github</a>
</span>
</footer>
</div>
<script src="{{ url_for('static', filename='vendor.js') }}"></script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

@ -0,0 +1,11 @@
{%- extends "base_sso.html" %}
{%- block content %}
{%- call macros.card() %}
<form class="form" method="post" role="form">
{{ macros.form_field(form.email) }}
{{ macros.form_field(form.pw) }}
{{ macros.form_fields(fields, label=False, class="btn btn-default") }}
</form>
{%- endcall %}
{%- endblock %}

@ -0,0 +1,5 @@
{%- extends "form_sso.html" %}
{%- block title %}
{% trans %}Sign in{% endtrans %}
{%- endblock %}

@ -0,0 +1,55 @@
<div class="sidebar text-sm">
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" role="menu">
<li class="nav-header text-uppercase text-primary" role="none">{% trans %}Go to{% endtrans %}</li>
{%- if config['ADMIN'] %}
<li class="nav-item">
<a href="{{ url_for('ui.client') }}" class="nav-link">
<i class="nav-icon fa fa-laptop"></i>
<p class="text">{% trans %}Client setup{% endtrans %}</p>
</a>
</li>
{%- endif %}
<li class="nav-item" role="none">
<a href="{{ config["WEBSITE"] }}" target="_blank" class="nav-link" role="menuitem" rel="noreferrer">
<i class="nav-icon fa fa-globe"></i>
<p>{% trans %}Website{% endtrans %} <i class="fas fa-external-link-alt text-xs"></i></p>
</a>
</li>
<li class="nav-item" role="none">
<a href="https://mailu.io" target="_blank" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-life-ring"></i>
<p class="text">{% trans %}Help{% endtrans %} <i class="fas fa-external-link-alt text-xs"></i></p>
</a>
</li>
{#-
Domain self-registration is only available when
- Admin is available
- Domain Self-registration is enabled
- The current user is not logged on
#}
{%- if config['DOMAIN_REGISTRATION'] and not current_user.is_authenticated and config['ADMIN'] %}
<li class="nav-item" role="none">
<a href="{{ url_for('ui.domain_signup') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-plus-square"></i>
<p class="text">{% trans %}Register a domain{% endtrans %}</p>
</a>
</li>
{%- endif %}
{#-
User self-registration is only available when
- Admin is available
- Self-registration is enabled
- The current user is not logged on
#}
{%- if not current_user.is_authenticated and signup_domains and config['ADMIN'] %}
<li class="nav-item" role="none">
<a href="{{ url_for('ui.user_signup') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-user-plus"></i>
<p class="text">{% trans %}Sign up{% endtrans %}</p>
</a>
</li>
{%- endif %}
</ul>
</nav>
</div>

@ -0,0 +1,3 @@
__all__ = [
'base', 'languages'
]

@ -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'))

@ -0,0 +1,7 @@
from mailu.sso import sso
import flask
@sso.route('/language/<language>', methods=['POST'])
def set_language(language=None):
flask.session['language'] = language
return flask.Response(status=200)

@ -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 <dareknowacki2001@gmail.com>\n"
"Language-Team: Polish <https://translate.tedomum.net/projects/mailu/admin/pl/"
">\n"
"Last-Translator: Marcin Siennicki <marcin@siennicki.eu>\n"
"Language: pl\n"
"Language-Team: Polish "
"<https://translate.tedomum.net/projects/mailu/admin/pl/>\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 <code>MX</code> points to this server"
msgstr ""
"Aby zarejestrować nową domenę, musisz najpierw skonfigurować strefę "
"domeny, aby domena <code> MX </code> wskazywała na ten serwer"
#: mailu/ui/templates/domain/signup.html:18
msgid ""
"If you do not know how to setup an <code>MX</code> record for your DNS "
"zone,\n"
" please contact your DNS provider or administrator. Also, please wait "
"a\n"
" couple minutes after the <code>MX</code> is set so the local server "
"cache\n"
" expires."
msgstr ""
"Jeśli nie wiesz, jak skonfigurować rekord <code> MX </code> dla swojej "
"strefy DNS,\n"
"skontaktuj się z dostawcą DNS lub administratorem. Proszę również "
"poczekać\n"
"kilka minut po ustawieniu <code> MX </code>, ż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 <code>MX</code> points to this server"
msgstr ""
"Aby zarejestrować nową domenę, musisz najpierw skonfigurować strefę domeny, "
"aby domena <code> MX </code> wskazywała na ten serwer"
#: mailu/ui/templates/domain/signup.html:18
msgid "If you do not know how to setup an <code>MX</code> record for your DNS zone,\n"
" please contact your DNS provider or administrator. Also, please wait a\n"
" couple minutes after the <code>MX</code> is set so the local server cache\n"
" expires."
msgstr ""
"Jeśli nie wiesz, jak skonfigurować rekord <code> MX </code> dla swojej "
"strefy DNS,\n"
"skontaktuj się z dostawcą DNS lub administratorem. Proszę również poczekać\n"
"kilka minut po ustawieniu <code> MX </code> , ż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"

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

@ -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 *

@ -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'))

@ -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 class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.admin, class_='mailselect') }}
{{ macros.form_field(form.submit) }}
</form>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -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 %}
<a class="btn btn-primary float-right" href="{{ url_for('.admin_create') }}">
{% trans %}Add administrator{% endtrans %}
</a>
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<thead>
<tr>
<th>{% trans %}Actions{% endtrans %}</th>
@ -19,14 +19,14 @@
</tr>
</thead>
<tbody>
{% for admin in admins %}
{%- for admin in admins %}
<tr>
<td>
<a href="{{ url_for('.admin_delete', admin=admin.email) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>
</td>
<td>{{ admin }}</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -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 class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.localpart, append='<span class="input-group-text">@'+domain.name+'</span>') }}
@ -18,5 +18,5 @@
{{ macros.form_field(form.comment) }}
{{ macros.form_field(form.submit) }}
</form>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -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 %}

@ -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 %}
<a class="btn btn-primary float-right" href="{{ url_for('.alias_create', domain_name=domain.name) }}">{% trans %}Add alias{% endtrans %}</a>
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<thead>
<tr>
<th>{% trans %}Actions{% endtrans %}</th>
@ -25,7 +25,7 @@
</tr>
</thead>
<tbody>
{% for alias in domain.aliases %}
{%- for alias in domain.aliases %}
<tr>
<td>
<a href="{{ url_for('.alias_edit', alias=alias.email) }}" title="{% trans %}Edit{% endtrans %}"><i class="fa fa-pencil"></i></a>&nbsp;
@ -34,10 +34,10 @@
<td>{{ alias }}</td>
<td>{{ alias.destination|join(', ') or '-' }}</td>
<td>{{ alias.comment or '' }}</td>
<td>{{ alias.created_at }}</td>
<td>{{ alias.updated_at or '' }}</td>
<td>{{ alias.created_at | format_date }}</td>
<td>{{ alias.updated_at | format_date }}</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -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 %}

@ -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 %}
<a class="btn btn-primary float-right" href="{{ url_for('.alternative_create', domain_name=domain.name) }}">{% trans %}Add alternative{% endtrans %}</a>
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<thead>
<tr>
<th>{% trans %}Actions{% endtrans %}</th>
<th>{% trans %}Name{% endtrans %}</th>
<th>{% trans %}Created{% endtrans %}</th>
<th>{% trans %}Last edit{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for alternative in domain.alternatives %}
{%- for alternative in domain.alternatives %}
<tr>
<td>
<a href="{{ url_for('.alternative_delete', alternative=alternative.name) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>
</td>
<td>{{ alternative }}</td>
<td>{{ alternative.created_at }}</td>
<td>{{ alternative.created_at | format_date }}</td>
<td>{{ alternative.updated_at | format_date }}</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -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 class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.announcement_subject) }}
{{ macros.form_field(form.announcement_body, rows=10) }}
{{ macros.form_field(form.submit) }}
</form>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -0,0 +1,15 @@
{%- extends "base.html" %}
{%- block title %}
{% trans %}Antispam{% endtrans %}
{%- endblock %}
{%- block subtitle %}
{% trans %}RSPAMD status page{% endtrans %}
{%- endblock %}
{%- block content %}
<div class="embed-responsive embed-responsive-1by1">
<iframe class="embed-responsive-item" src="{{ config["WEB_ADMIN"] }}/antispam/"></iframe>
</div>
{%- endblock %}

@ -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 %}
<!doctype html>
<html>
<html lang="{{ session['language'] }}" data-static="/static/">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('.static', filename='vendor.css') }}">
<link rel="stylesheet" href="{{ url_for('.static', filename='app.css') }}">
<title>Mailu-Admin - {{ config["SITENAME"] }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{% trans %}Admin page for{% endtrans %} {{ config["SITENAME"] }}">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Mailu-Admin | {{ config["SITENAME"] }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
</head>
<body class="hold-transition sidebar-mini layout-fixed">
<div class="wrapper">
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a>
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars" title="{% trans %}toggle sidebar{% endtrans %}" aria-expanded="false"></i><span class="sr-only">{% trans %}toggle sidebar{% endtrans %}</span></a>
</li>
<li class="nav-item">
{%- for page, url in path %}
{%- if loop.index > 1 %}
<i class="fas fa-greater-than text-xs text-gray" aria-hidden="true"></i>
{%- endif %}
{%- if url %}
<a class="nav-link d-inline-block" href="{{ url }}" role="button">{{ page }}</a>
{%- else %}
<span class="nav-link d-inline-block">{{ page }}</span>
{%- endif %}
{%- endfor %}
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item dropdown">
<a class="nav-link" data-toggle="dropdown" href="#" aria-expanded="false">{{ session['language'] }}</a>
<div class="dropdown-menu dropdown-menu-right p-0">
{% for language in session['available_languages'] %}
<a class="dropdown-item {% if language == session['language'] %}active{% endif %} " href="{{ url_for('.set_language', language=language) }}">{{ language }}</a>
{% endfor %}
<a class="nav-link" data-toggle="dropdown" href="#" aria-expanded="false">
<i class="fas fa-language text-xl" aria-hidden="true" title="{% trans %}change language{% endtrans %}"></i><span class="sr-only">Language</span>
<span class="badge badge-primary navbar-badge">{{ session['language'] }}</span></a>
<div class="dropdown-menu dropdown-menu-right p-0" id="mailu-languages">
{%- for locale in config.translations.values() %}
<a class="dropdown-item{% if locale|string() == session['language'] %} active{% endif %}" href="{{ url_for('.set_language', language=locale) }}">{{ locale.get_language_name().title() }}</a>
{%- endfor %}
</div>
</li>
</ul>
</nav>
<aside class="main-sidebar sidebar-dark-primary">
<a href="{{ config["WEB_ADMIN"] }}" class="brand-link">
<span class="brand-text font-weight-light">{{ config["SITENAME"] }}</span>
<aside class="main-sidebar sidebar-dark-primary nav-compact elevation-4">
<a href="{{ url_for('.domain_list' if current_user.manager_of or current_user.global_admin else '.user_settings') }}" class="brand-link bg-mailu-logo"{% if config["LOGO_BACKGROUND"] %} style="background-color:{{ config["LOGO_BACKGROUND"] }}!important;"{% endif %}>
<img src="{{ config["LOGO_URL"] if config["LOGO_URL"] else url_for('static', filename='mailu.png') }}" width="33" height="33" alt="Mailu" class="brand-image mailu-logo img-circle elevation-3">
<span class="brand-text font-weight-light">{{ config["SITENAME"] }}</span>
</a>
{% block sidebar %}
{% include "sidebar.html" %}
{% endblock %}
{%- include "sidebar.html" %}
</aside>
<div class="content-wrapper">
<div class="content-wrapper text-sm">
<section class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0">{% block title %}{% endblock %}</h1>
<h1 class="m-0">{%- block title %}{%- endblock %}</h1>
<small>{% block subtitle %}{% endblock %}</small>
</div>
<div class="col-sm-6">
{% block main_action %}
{% endblock %}
{%- block main_action %}{%- endblock %}
</div>
</div>
</div>
</section>
<div class="content">
{{ utils.flashed_messages(container=False) }}
{% block content %}{% endblock %}
{{ utils.flashed_messages(container=False, default_category='success') }}
{%- block content %}{%- endblock %}
</div>
</div>
<footer class="main-footer">
Built with <i class="fa fa-heart"></i> using <a class="white-text" href="http://flask.pocoo.org/">Flask</a> and
<a class="white-text" href="https://adminlte.io/themes/v3/index3.html">AdminLTE</a>
<span class="pull-right"><i class="fa fa-code-fork"></i>on <a class="white-text" href="https://github.com/Mailu/Mailu">Github</a></a></span>
Built with <i class="fa fa-heart text-danger" aria-hidden="true"></i><span class="sr-only">love</span>
using <a href="https://flask.palletsprojects.com/">Flask</a>
and <a href="https://adminlte.io/themes/v3/index3.html">AdminLTE</a>.
<span class="fa-pull-right">
<i class="fa fa-code-branch" aria-hidden="true"></i><span class="sr-only">fork</span>
on <a href="https://github.com/Mailu/Mailu">Github</a>
</span>
</footer>
</div>
<script src="{{ url_for('.static', filename='vendor.js') }}"></script>
<script src="{{ url_for('.static', filename='app.js') }}"></script>
<script src="{{ url_for('static', filename='vendor.js') }}"></script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

@ -1,17 +1,15 @@
<!--TODO add translations for: configure your client, Incoming mail and Outgoing mail-->
{%- extends "base.html" %}
{% extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Client setup{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
configure your email client
{% endblock %}
{%- block subtitle %}
{% trans %}configure your email client{% endtrans %}
{%- endblock %}
{% block content %}
{% call macros.table(title="Incoming mail", datatable=False) %}
{%- block content %}
{%- call macros.table(title=_("Incoming mail"), datatable=False) %}
<tbody>
<tr>
<th>{% trans %}Mail protocol{% endtrans %}</th>
@ -23,20 +21,20 @@ configure your email client
</tr>
<tr>
<th>{% trans %}Server name{% endtrans %}</th>
<td><pre>{{ config["HOSTNAMES"].split(',')[0] }}</pre></td>
<td><pre class="pre-config border bg-light">{{ config["HOSTNAMES"] }}</pre></td>
</tr>
<tr>
<th>{% trans %}Username{% endtrans %}</th>
<td><pre>{{ current_user if current_user.is_authenticated else "******" }}</pre></td>
<td><pre class="pre-config border bg-light">{{ current_user if current_user.is_authenticated else "******" }}</pre></td>
</tr>
<tr>
<th>{% trans %}Password{% endtrans %}</th>
<td><pre>*******</pre></td>
<td><pre class="pre-config border bg-light">*******</pre></td>
</tr>
</tbody>
{% endcall %}
{%- endcall %}
{% call macros.table(title="Outgoing mail", datatable=False) %}
{%- call macros.table(title=_("Outgoing mail"), datatable=False) %}
<tbody>
<tr>
<th>{% trans %}Mail protocol{% endtrans %}</th>
@ -48,16 +46,16 @@ configure your email client
</tr>
<tr>
<th>{% trans %}Server name{% endtrans %}</th>
<td><pre>{{ config["HOSTNAMES"].split(',')[0] }}</pre></td>
<td><pre class="pre-config border bg-light">{{ config["HOSTNAMES"] }}</pre></td>
</tr>
<tr>
<th>{% trans %}Username{% endtrans %}</th>
<td><pre>{{ current_user if current_user.is_authenticated else "******" }}</pre></td>
<td><pre class="pre-config border bg-light">{{ current_user if current_user.is_authenticated else "******" }}</pre></td>
</tr>
<tr>
<th>{% trans %}Password{% endtrans %}</th>
<td><pre>*******</pre></td>
<td><pre class="pre-config border bg-light">*******</pre></td>
</tr>
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,16 +1,16 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Confirm action{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ action }}
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.card(theme="warning") %}
{%- block content %}
{%- call macros.card(theme="warning") %}
<p>{% trans action %}You are about to {{ action }}. Please confirm your action.{% endtrans %}</p>
{{ macros.form(form) }}
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,14 +1,14 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Docker error{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ action }}
{% endblock %}
{%- endblock %}
{% block content %}
{%- block content %}
<p>{% trans action %}An error occurred while talking to the Docker server.{% endtrans %}</p>
<pre>{{ error }}</pre>
{% endblock %}
<pre class="pre-config border bg-light">{{ error }}</pre>
{%- endblock %}

@ -1,21 +1,20 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}New domain{% endtrans %}
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.card() %}
{%- block content %}
{%- call macros.card() %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.name) }}
{{ macros.form_fields((form.max_users, form.max_aliases)) }}
{{ macros.form_field(form.max_quota_bytes, step=1000000000, max=50000000000,
prepend='<span class="input-group-text"><span id="quota">'+((form.max_quota_bytes.data//1000000000).__str__() if form.max_quota_bytes.data else '∞')+'</span> GiB</span>',
oninput='$("#quota").text(this.value == 0 ? "∞" : this.value/1000000000);') }}
{{ macros.form_field(form.max_quota_bytes, step=10**9, max=50*10**9, data_infinity="true",
prepend='<span class="input-group-text"><span id="max_quota_bytes_value"></span>&nbsp;GB</span>') }}
{{ macros.form_field(form.signup_enabled) }}
{{ macros.form_field(form.comment) }}
{{ macros.form_field(form.submit) }}
</form>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,66 +1,71 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Domain details{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ domain.name }}
{% endblock %}
{%- endblock %}
{% block main_action %}
{% if current_user.global_admin %}
{%- block main_action %}
{%- if current_user.global_admin %}
<a class="btn btn-primary float-right" href="{{ url_for(".domain_genkeys", domain_name=domain.name) }}">
{% if domain.dkim_publickey %}
{%- if domain.dkim_publickey %}
{% trans %}Regenerate keys{% endtrans %}
{% else %}
{%- else %}
{% trans %}Generate keys{% endtrans %}
{% endif %}
{%- endif %}
</a>
{% endif %}
{% endblock %}
{%- endif %}
{%- endblock %}
{% block content %}
{% call macros.table(datatable=False) %}
{% set hostname = config["HOSTNAMES"].split(",")[0] %}
{%- block content %}
{%- call macros.table(datatable=False) %}
<tr>
<th>{% trans %}Domain name{% endtrans %}</th>
<td>{{ domain.name }}</td>
</tr>
<tr>
<th>{% trans %}DNS MX entry{% endtrans %} <i class="fa {{ 'fa-check-circle' if domain.check_mx() else 'fa-exclamation-circle' }}"></i></th>
<td><pre>{{ domain.name }}. 600 IN MX 10 {{ hostname }}.</pre></td>
<th>{% trans %}DNS MX entry{% endtrans %} <i class="fa {{ 'fa-check-circle text-success' if domain.check_mx() else 'fa-exclamation-circle text-danger' }}"></i></th>
<td>{{ macros.clip("dns_mx") }}<pre id="dns_mx" class="pre-config border bg-light">{{ domain.dns_mx }}</pre></td>
</tr>
<tr>
<th>{% trans %}DNS SPF entries{% endtrans %}</th>
<td><pre>
{{ domain.name }}. 600 IN TXT "v=spf1 mx a:{{ hostname }} -all"</pre></td>
<td>{{ macros.clip("dns_spf") }}<pre id="dns_spf" class="pre-config border bg-light">{{ domain.dns_spf }}</pre>
</td>
</tr>
{% if domain.dkim_publickey %}
{%- if domain.dkim_publickey %}
<tr>
<th>{% trans %}DKIM public key{% endtrans %}</th>
<td><pre style="white-space: pre-wrap; word-wrap: break-word;">{{ domain.dkim_publickey }}</pre></td>
<td>{{ macros.clip("dkim_key") }}<pre id="dkim_key" class="pre-config border bg-light">{{ domain.dkim_publickey }}</pre></td>
</tr>
<tr>
<th>{% trans %}DNS DKIM entry{% endtrans %}</th>
<td><pre style="white-space: pre-wrap; word-wrap: break-word;">{{ config["DKIM_SELECTOR"] }}._domainkey.{{ domain.name }}. 600 IN TXT "v=DKIM1; k=rsa; p={{ domain.dkim_publickey }}"</pre></td>
<td>{{ macros.clip("dns_dkim") }}<pre id="dns_dkim" class="pre-config border bg-light">{{ domain.dns_dkim }}</pre></td>
</tr>
<tr>
<th>{% trans %}DNS DMARC entry{% endtrans %}</th>
<td><pre>_dmarc.{{ domain.name }}. 600 IN TXT "v=DMARC1; p=reject;{% if config["DMARC_RUA"] %} rua=mailto:{{ config["DMARC_RUA"] }}@{{ config["DOMAIN"] }};{% endif %}{% if config["DMARC_RUF"] %} ruf=mailto:{{ config["DMARC_RUF"] }}@{{ config["DOMAIN"] }};{% endif %} adkim=s; aspf=s"</pre></td>
<td>
{{ macros.clip("dns_dmarc") }}<pre id="dns_dmarc" class="pre-config border bg-light">{{ domain.dns_dmarc }}</pre>
{{ macros.clip("dns_dmarc_report") }}<pre id="dns_dmarc_report" class="pre-config border bg-light">{{ domain.dns_dmarc_report }}</pre>
</td>
</tr>
{% endif %}
{%- endif %}
{%- set tlsa_record=domain.dns_tlsa %}
{%- if tlsa_record %}
<tr>
<th>{% trans %}DNS TLSA entry{% endtrans %}</br><span class="text-secondary text-xs font-weight-normal">Let's Encrypt</br>ISRG Root X1</span></th>
<td>{{ macros.clip("dns_tlsa") }}<pre id="dns_tlsa" class="pre-config border bg-light">{{ tlsa_record }}</pre></td>
</tr>
{%- endif %}
<tr>
<th>{% trans %}DNS client auto-configuration (RFC6186) entries{% endtrans %}</th>
<td>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_submission._tcp.{{ domain.name }}. 600 IN SRV 1 1 587 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_imap._tcp.{{ domain.name }}. 600 IN SRV 100 1 143 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_pop3._tcp.{{ domain.name }}. 600 IN SRV 100 1 110 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
{% if config["TLS_FLAVOR"] != "notls" %}
<pre style="white-space: pre-wrap; word-wrap: break-word;">_submissions._tcp.{{ domain.name }}. 600 IN SRV 10 1 465 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_imaps._tcp.{{ domain.name }}. 600 IN SRV 10 1 993 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_pop3s._tcp.{{ domain.name }}. 600 IN SRV 10 1 995 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
{% endif %}</td>
<td>{{ macros.clip("dns_autoconfig") }}<pre id="dns_autoconfig" class="pre-config border bg-light">
{%- for line in domain.dns_autoconfig %}
{{ line }}
{%- endfor -%}
</pre></td>
</tr>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,9 +1,9 @@
{% extends "domain/create.html" %}
{%- extends "domain/create.html" %}
{% block title %}
{%- block title %}
{% trans %}Edit domain{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ domain }}
{% endblock %}
{%- endblock %}

@ -1,17 +1,17 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Domain list{% endtrans %}
{% endblock %}
{%- endblock %}
{% block main_action %}
{% if current_user.global_admin %}
{%- block main_action %}
{%- if current_user.global_admin %}
<a class="btn btn-primary float-right" href="{{ url_for('.domain_create') }}">{% trans %}New domain{% endtrans %}</a>
{% endif %}
{% endblock %}
{%- endif %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<thead>
<tr>
<th>{% trans %}Actions{% endtrans %}</th>
@ -25,31 +25,31 @@
</tr>
</thead>
<tbody>
{% for domain in current_user.get_managed_domains() %}
{%- for domain in current_user.get_managed_domains() %}
<tr>
<td>
<a href="{{ url_for('.domain_details', domain_name=domain.name) }}" title="{% trans %}Details{% endtrans %}"><i class="fa fa-list"></i></a>&nbsp;
{% if current_user.global_admin %}
{%- if current_user.global_admin %}
<a href="{{ url_for('.domain_edit', domain_name=domain.name) }}" title="{% trans %}Edit{% endtrans %}"><i class="fas fa-pencil-alt"></i></a>&nbsp;
<a href="{{ url_for('.domain_delete', domain_name=domain.name) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>&nbsp;
{% endif %}
{%- endif %}
</td>
<td>
<a href="{{ url_for('.user_list', domain_name=domain.name) }}" title="{% trans %}Users{% endtrans %}"><i class="far fa-envelope"></i></a>&nbsp;
<a href="{{ url_for('.alias_list', domain_name=domain.name) }}" title="{% trans %}Aliases{% endtrans %}"><i class="fa fa-at"></i></a>&nbsp;
<a href="{{ url_for('.manager_list', domain_name=domain.name) }}" title="{% trans %}Managers{% endtrans %}"><i class="fa fa-user"></i></a>&nbsp;
{% if current_user.global_admin %}
{%- if current_user.global_admin %}
<a href="{{ url_for('.alternative_list', domain_name=domain.name) }}" title="{% trans %}Alternatives{% endtrans %}"><i class="fa fa-asterisk"></i></a>&nbsp;
{% endif %}
{%- endif %}
</td>
<td>{{ domain.name }}</td>
<td>{{ domain.users | count }} / {{ '∞' if domain.max_users == -1 else domain.max_users }}</td>
<td>{{ domain.aliases | count }} / {{ '∞' if domain.max_aliases == -1 else domain.max_aliases }}</td>
<td>{{ domain.comment or '' }}</td>
<td>{{ domain.created_at }}</td>
<td>{{ domain.updated_at or '' }}</td>
<td>{{ domain.created_at | format_date }}</td>
<td>{{ domain.updated_at | format_date }}</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,18 +1,18 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Register a domain{% endtrans %}
{% endblock %}
{%- endblock %}
{% block content %}
{%- block content %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{% call macros.card(title="Requirements") %}
{%- call macros.card(title="Requirements") %}
<p>{% trans %}In order to register a new domain, you must first setup the
domain zone so that the domain <code>MX</code> points to this server{% endtrans %}
(<code>{{ config["HOSTNAMES"].split(",")[0] }}</code>).
(<code>{{ config["HOSTNAME"] }}</code>).
</p>
<p>
{% trans %}If you do not know how to setup an <code>MX</code> record for your DNS zone,
@ -20,17 +20,17 @@
couple minutes after the <code>MX</code> is set so the local server cache
expires.{% endtrans %}
</p>
{% endcall %}
{%- endcall %}
{% call macros.card() %}
{% if form.localpart %}
{%- call macros.card() %}
{%- if form.localpart %}
{{ macros.form_fields((form.localpart, form.name), append='<span class="input-group-text">@</span>') }}
{{ macros.form_fields((form.pw, form.pw2)) }}
{% else %}
{%- else %}
{{ macros.form_field(form.name) }}
{% endif %}
{%- endif %}
{{ macros.form_field(form.captcha) }}
{{ macros.form_field(form.submit) }}
{% endcall %}
{%- endcall %}
</form>
{% endblock %}
{%- endblock %}

@ -1,31 +1,31 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Add a fetched account{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ user }}
{% endblock %}
{%- endblock %}
{% block content %}
{%- block content %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{% call macros.card(title="Remote server") %}
{%- call macros.card(title="Remote server") %}
{{ macros.form_field(form.protocol) }}
{{ macros.form_fields((form.host, form.port)) }}
{{ macros.form_field(form.tls) }}
{% endcall %}
{%- endcall %}
{% call macros.card(title="Authentication") %}
{%- call macros.card(title="Authentication") %}
{{ macros.form_field(form.username) }}
{{ macros.form_field(form.password) }}
{% endcall %}
{%- endcall %}
{% call macros.card(title="Settings") %}
{%- call macros.card(title="Settings") %}
{{ macros.form_field(form.keep) }}
{% endcall %}
{%- endcall %}
{{ macros.form_field(form.submit) }}
</form>
{% endblock %}
{%- endblock %}

@ -1,9 +1,9 @@
{% extends "fetch/create.html" %}
{%- extends "fetch/create.html" %}
{% block title %}
{%- block title %}
{% trans %}Update a fetched account{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ user }}
{% endblock %}
{%- endblock %}

@ -1,19 +1,19 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Fetched accounts{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ user }}
{% endblock %}
{%- endblock %}
{% block main_action %}
{%- block main_action %}
<a class="btn btn-primary float-right" href="{{ url_for('.fetch_create', user_email=user.email) }}">{% trans %}Add an account{% endtrans %}</a>
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<thead>
<tr>
<th>{% trans %}Actions{% endtrans %}</th>
@ -27,7 +27,7 @@
</tr>
</thead>
<tbody>
{% for fetch in user.fetches %}
{%- for fetch in user.fetches %}
<tr>
<td>
<a href="{{ url_for('.fetch_edit', fetch_id=fetch.id) }}" title="{% trans %}Edit{% endtrans %}"><i class="fa fa-pencil"></i></a>&nbsp;
@ -36,12 +36,12 @@
<td>{{ fetch.protocol }}{{ 's' if fetch.tls else '' }}://{{ fetch.host }}:{{ fetch.port }}</td>
<td>{{ fetch.username }}</td>
<td>{% if fetch.keep %}{% trans %}yes{% endtrans %}{% else %}{% trans %}no{% endtrans %}{% endif %}</td>
<td>{{ fetch.last_check or '-' }}</td>
<td>{{ fetch.last_check | format_datetime or '-' }}</td>
<td>{{ fetch.error or '-' }}</td>
<td>{{ fetch.created_at }}</td>
<td>{{ fetch.updated_at or '' }}</td>
<td>{{ fetch.created_at | format_date }}</td>
<td>{{ fetch.updated_at | format_date }}</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,7 +1,7 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block content %}
{% call macros.card() %}
{%- block content %}
{%- call macros.card() %}
{{ macros.form(form) }}
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,9 +0,0 @@
{% extends "form.html" %}
{% block title %}
{% trans %}Sign in{% endtrans %}
{% endblock %}
{% block subtitle %}
{% trans %}to access the administration tools{% endtrans %}
{% endblock %}

@ -1,104 +1,133 @@
{% macro form_errors(form) %}
{% if form.errors %}
{% for fieldname, errors in form.errors.items() %}
{% if bootstrap_is_hidden_field(form[fieldname]) %}
{% for error in errors %}
{%- macro form_errors(form) %}
{%- if form.errors %}
{%- for fieldname, errors in form.errors.items() %}
{%- if bootstrap_is_hidden_field(form[fieldname]) %}
{%- for error in errors %}
<p class="error">{{error}}</p>
{% endfor %}
{% endif %}
{% endfor %}
{% endif %}
{% endmacro %}
{%- endfor %}
{%- endif %}
{%- endfor %}
{%- endif %}
{%- endmacro %}
{% macro form_field_errors(field) %}
{% if field.errors %}
{% for error in field.errors %}
{%- macro form_field_errors(field) %}
{%- if field.errors %}
{%- for error in field.errors %}
<p class="help-block inline">{{ error }}</p>
{% endfor %}
{% endif %}
{% endmacro %}
{%- endfor %}
{%- endif %}
{%- endmacro %}
{% macro form_fields(fields, prepend='', append='', label=True) %}
{% set width = (12 / fields|length)|int %}
{%- macro form_fields(fields, prepend='', append='', label=True) %}
{%- set width = (12 / fields|length)|int %}
<div class="form-group">
<div class="row">
{% for field in fields %}
{%- for field in fields %}
<div class="col-lg-{{ width }} col-xs-12 {{ 'has-error' if field.errors else '' }}">
{{ form_individual_field(field, prepend=prepend, append=append, label=label, **kwargs) }}
{%- if field.__class__.__name__ == 'list' %}
{%- for subfield in field %}
{{ form_individual_field(subfield, prepend=prepend, append=append, label=label, **kwargs) }}
{%- endfor %}
{%- else %}
{{ form_individual_field(field, prepend=prepend, append=append, label=label, **kwargs) }}
{%- endif %}
</div>
{% endfor %}
{%- endfor %}
</div>
</div>
{% endmacro %}
{%- endmacro %}
{% macro form_individual_field(field, prepend='', append='', label=True, class_="") %}
{% if field.type == "BooleanField" %}
{{ field(**kwargs) }}<span>&nbsp;&nbsp;</span>
{{ field.label if label else '' }}
{% else %}
{%- macro form_individual_field(field, prepend='', append='', label=True, class_="") %}
{%- if field.type == "BooleanField" %}
{{ field(**kwargs) }}<span>&nbsp;&nbsp;</span>{{ field.label if label else '' }}
{%- else %}
{{ field.label if label else '' }}{{ form_field_errors(field) }}
{% if prepend %}<div class="input-group-prepend">{% endif %}
{% if append %}<div class="input-group-append">{% endif %}
{{ prepend|safe }}{{ field(class_="form-control " + class_, **kwargs) }}{{ append|safe }}
{% if prepend or append %}</div>{% endif %}
{% endif %}
{% endmacro %}
{%- if prepend %}<div class="input-group-prepend">{%- elif append %}<div class="input-group-append">{%- endif %}
{{ prepend|safe }}{{ field(class_=("form-control " + class_) if class_ else "form-control", **kwargs) }}{{ append|safe }}
{%- if prepend or append %}</div>{%- endif %}
{%- endif %}
{%- endmacro %}
{% macro form_field(field) %}
{% if field.type == 'SubmitField' %}
{{ form_fields((field,), label=False, class="btn btn-default", **kwargs) }}
{% else %}
{{ form_fields((field,), **kwargs) }}
{% endif %}
{% endmacro %}
{%- macro form_field(field) %}
{%- if field.type == 'SubmitField' %}
{{- form_fields((field,), label=False, class="btn btn-default", **kwargs) }}
{%- else %}
{{- form_fields((field,), **kwargs) }}
{%- endif %}
{%- endmacro %}
{% macro form(form) %}
{%- macro form(form) %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{% for field in form %}
{% if bootstrap_is_hidden_field(field) %}
{%- for field in form %}
{%- if bootstrap_is_hidden_field(field) %}
{{ field() }}
{% else %}
{%- else %}
{{ form_field(field) }}
{% endif %}
{% endfor %}
{%- endif %}
{%- endfor %}
</form>
{% endmacro %}
{%- endmacro %}
{% macro card(title=None, theme="primary", header=True) %}
{%- macro card(title=None, theme="primary", header=True) %}
<div class="row">
<div class="col-lg-12">
<div class="card card-outline card-{{ theme }}">
{% if header %}
{%- if header %}
<div class="card-header border-0">
{% if title %}
{%- if title %}
<h3 class="card-title">{{ title }}</h3>
{% endif %}
{%- endif %}
</div>
{% endif %}
{%- endif %}
<div class="card-body">
{{ caller() }}
{{- caller() }}
</div>
</div>
</div>
</div>
{% endmacro %}
{%- endmacro %}
{% macro table(title=None, theme="primary", datatable=True) %}
{%- macro table(title=None, theme="primary", datatable=True) %}
<div class="row">
<div class="col-lg-12">
<div class="card card-outline card-{{ theme }}">
{%- if title %}
<div class="card-header border-0">
{% if title %}
<h3 class="card-title">{{ title }}</h3>
{% endif %}
</div>
{%- endif %}
<div class="card-body">
<table class="table table-bordered {% if datatable %} dataTable {% endif %}">
{{ caller() }}
<table class="table table-bordered{% if datatable %} dataTable{% endif %}">
{{- caller() }}
</table>
</div>
</div>
</div>
</div>
{% endmacro %}
{%- endmacro %}
{%- macro fieldset(title=None, field=None, enabled=None, fields=None) %}
{%- if field or title %}
<fieldset{% if not enabled %} disabled{% endif %}>
{%- if field %}
<legend>{{ form_individual_field(field) }}</legend>
{%- else %}
<legend>{{ title }}</legend>
{%- endif %}
{%- endif %}
{{- caller() }}
{%- if fields %}
{%- set kwargs = {"enabled" if enabled else "disabled": ""} %}
{%- for field in fields %}
{{ form_field(field, **kwargs) }}
{%- endfor %}
{%- endif %}
</fieldset>
{%- endmacro %}
{%- macro clip(target, title=_("copy to clipboard"), icon="copy", color="primary", action="copy") %}
<button class="btn btn-{{ color }} btn-xs btn-clip float-right ml-2 mt-1" data-clipboard-action="{{ action }}" data-clipboard-target="#{{ target }}">
<i class="fas fa-{{ icon }}" title="{{ title }}" aria-expanded="false"></i><span class="sr-only">{{ title }}</span>
</button>
{%- endmacro %}

@ -1,19 +1,19 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Add a manager{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ domain }}
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.card() %}
{%- block content %}
{%- call macros.card() %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.manager, class_='mailselect') }}
{{ macros.form_field(form.submit) }}
</form>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,19 +1,19 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Manager list{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ domain.name }}
{% endblock %}
{%- endblock %}
{% block main_action %}
{%- block main_action %}
<a class="btn btn-primary float-right" href="{{ url_for('.manager_create', domain_name=domain.name) }}">{% trans %}Add manager{% endtrans %}</a>
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<thead>
<tr>
<th>{% trans %}Actions{% endtrans %}</th>
@ -21,14 +21,14 @@
</tr>
</thead>
<tbody>
{% for manager in domain.managers %}
{%- for manager in domain.managers %}
<tr>
<td>
<a href="{{ url_for('.manager_delete', domain_name=domain.name, user_email=manager.email) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>
</td>
<td>{{ manager }}</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,5 +1,5 @@
{% extends "form.html" %}
{%- extends "form.html" %}
{% block title %}
{%- block title %}
{% trans %}New relay domain{% endtrans %}
{% endblock %}
{%- endblock %}

@ -1,9 +1,9 @@
{% extends "form.html" %}
{%- extends "form.html" %}
{% block title %}
{%- block title %}
{% trans %}Edit relayd domain{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ relay }}
{% endblock %}
{%- endblock %}

@ -1,17 +1,17 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Relayed domain list{% endtrans %}
{% endblock %}
{%- endblock %}
{% block main_action %}
{% if current_user.global_admin %}
{%- block main_action %}
{%- if current_user.global_admin %}
<a class="btn btn-primary float-right" href="{{ url_for('.relay_create') }}">{% trans %}New relayed domain{% endtrans %}</a>
{% endif %}
{% endblock %}
{%- endif %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<thead>
<tr>
<th>{% trans %}Actions{% endtrans %}</th>
@ -23,7 +23,7 @@
</tr>
</thead>
<tbody>
{% for relay in relays %}
{%- for relay in relays %}
<tr>
<td>
<a href="{{ url_for('.relay_edit', relay_name=relay.name) }}" title="{% trans %}Edit{% endtrans %}"><i class="fa fa-pencil"></i></a>&nbsp;
@ -32,10 +32,10 @@
<td>{{ relay.name }}</td>
<td>{{ relay.smtp or '-' }}</td>
<td>{{ relay.comment or '' }}</td>
<td>{{ relay.created_at }}</td>
<td>{{ relay.updated_at or '' }}</td>
<td>{{ relay.created_at | format_date }}</td>
<td>{{ relay.updated_at | format_date }}</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,144 +1,156 @@
<div class="sidebar">
{% if current_user.is_authenticated %}
<div class="sidebar text-sm">
{%- if current_user.is_authenticated %}
<div class="user-panel mt-3 pb-3 mb-3 d-flex">
<div class="image">
<div class="div-circle elevation-2"><i class="fa fa-user text-lg text-dark"></i></div>
</div>
<div class="info">
<span class="text-center text-primary">{{ current_user }}</span>
<a href="{{ url_for('.user_settings') }}" class="d-block">{{ current_user }}</a>
</div>
</div>
{% endif %}
{%- endif %}
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" role="menu">
{% if current_user.is_authenticated %}
<li class="nav-header">{% trans %}My account{% endtrans %}</li>
<li class="nav-item">
<a href="{{ url_for('.user_settings') }}" class="nav-link">
{%- if current_user.is_authenticated %}
<li class="nav-header text-uppercase text-primary" role="none">{% trans %}My account{% endtrans %}</li>
<li class="nav-item" role="none">
<a href="{{ url_for('.user_settings') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-wrench"></i>
<p class="text">{% trans %}Settings{% endtrans %}</p>
<p>{% trans %}Settings{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('.user_password') }}" class="nav-link">
<li class="nav-item" role="none">
<a href="{{ url_for('.user_password') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-lock"></i>
<p class="text">{% trans %}Update password{% endtrans %}</p>
<p>{% trans %}Update password{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('.user_reply') }}" class="nav-link">
<li class="nav-item" role="none">
<a href="{{ url_for('.user_reply') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-plane"></i>
<p class="text">{% trans %}Auto-reply{% endtrans %}</p>
<p>{% trans %}Auto-reply{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('.fetch_list') }}" class="nav-link">
<li class="nav-item" role="none">
<a href="{{ url_for('.fetch_list') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-download"></i>
<p class="text">{% trans %}Fetched accounts{% endtrans %}</p>
<p>{% trans %}Fetched accounts{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('.token_list') }}" class="nav-link">
<li class="nav-item" role="none">
<a href="{{ url_for('.token_list') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-ticket-alt"></i>
<p class="text">{% trans %}Authentication tokens{% endtrans %}</p>
<p>{% trans %}Authentication tokens{% endtrans %}</p>
</a>
</li>
{% if current_user.manager_of or current_user.global_admin %}
<li class="nav-header">{% trans %}Administration{% endtrans %}</li>
{% endif %}
{% if current_user.global_admin %}
<li class="nav-item">
<a href="{{ url_for('.announcement') }}" class="nav-link">
<i class="nav-icon fa fa-bullhorn"></i>
<p class="text">{% trans %}Announcement{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('.admin_list') }}" class="nav-link">
<i class="nav-icon fa fa-user"></i>
<p class="text">{% trans %}Administrators{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('.relay_list') }}" class="nav-link">
<i class="nav-icon fa fa-reply-all"></i>
<p class="text">{% trans %}Relayed domains{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a href="{{ config["WEB_ADMIN"] }}/antispam/" target="_blank" class="nav-link">
<i class="nav-icon fas fa-trash-alt"></i>
<p class="text">{% trans %}Antispam{% endtrans %}</p>
</a>
</li>
{% endif %}
{% if current_user.manager_of or current_user.global_admin %}
<li class="nav-item">
<a href="{{ url_for('.domain_list') }}" class="nav-link">
<i class="nav-icon fa fa-envelope"></i>
<p class="text">{% trans %}Mail domains{% endtrans %}</p>
</a>
</li>
{% endif %}
{% endif %}
<li class="nav-header">{% trans %}Go to{% endtrans %}</li>
{% if config["WEBMAIL"] != "none" %}
<li class="nav-item">
<a href="{{ config["WEB_WEBMAIL"] }}" target="_blank" class="nav-link">
<i class="nav-icon far fa-envelope"></i>
<p class="text">{% trans %}Webmail{% endtrans %}</p>
</a>
</li>
{% endif %}
<li class="nav-item">
<a href="{{ url_for('.client') }}" class="nav-link">
{%- if current_user.is_authenticated %}
<li class="nav-item" role="none">
<a href="{{ url_for('.client') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-laptop"></i>
<p class="text">{% trans %}Client setup{% endtrans %}</p>
<p>{% trans %}Client setup{% endtrans %}</p>
</a>
</li>
<li class="nav-item">
<a href="{{ config["WEBSITE"] }}" target="_blank" class="nav-link">
{%- endif %}
{%- if current_user.manager_of or current_user.global_admin %}
<li class="nav-header text-uppercase text-primary" role="none">{% trans %}Administration{% endtrans %}</li>
{%- endif %}
{%- if current_user.global_admin %}
<li class="nav-item" role="none">
<a href="{{ url_for('.announcement') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-bullhorn"></i>
<p>{% trans %}Announcement{% endtrans %}</p>
</a>
</li>
<li class="nav-item" role="none">
<a href="{{ url_for('.admin_list') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-user"></i>
<p>{% trans %}Administrators{% endtrans %}</p>
</a>
</li>
<li class="nav-item" role="none">
<a href="{{ url_for('.relay_list') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-reply-all"></i>
<p>{% trans %}Relayed domains{% endtrans %}</p>
</a>
</li>
<li class="nav-item" role="none">
<a href="{{ config["WEB_ADMIN"] }}/antispam/" data-clicked="{{ url_for('.antispam') }}" target="_blank" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-trash-alt"></i>
<p>{% trans %}Antispam{% endtrans %}</p>
</a>
</li>
{%- endif %}
{%- if current_user.manager_of or current_user.global_admin %}
<li class="nav-item" role="none">
<a href="{{ url_for('.domain_list') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-envelope"></i>
<p>{% trans %}Mail domains{% endtrans %}</p>
</a>
</li>
{%- endif %}
{%- endif %}
<li class="nav-header text-uppercase text-primary" role="none">{% trans %}Go to{% endtrans %}</li>
{%- if config["WEBMAIL"] != "none" and current_user.is_authenticated %}
<li class="nav-item" role="none">
<a href="{{ config["WEB_WEBMAIL"] }}" target="_blank" class="nav-link" role="menuitem">
<i class="nav-icon far fa-envelope"></i>
<p>{% trans %}Webmail{% endtrans %} <i class="fas fa-external-link-alt text-xs"></i></p>
</a>
</li>
{%- endif %}
{%- if not current_user.is_authenticated %}
<li class="nav-item" role="none">
<a href="{{ url_for('.client') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-laptop"></i>
<p>{% trans %}Client setup{% endtrans %}</p>
</a>
</li>
{%- endif %}
<li class="nav-item" role="none">
<a href="{{ config["WEBSITE"] }}" target="_blank" class="nav-link" role="menuitem" rel="noreferrer">
<i class="nav-icon fa fa-globe"></i>
<p class="text">{% trans %}Website{% endtrans %}</p>
<p>{% trans %}Website{% endtrans %} <i class="fas fa-external-link-alt text-xs"></i></p>
</a>
</li>
<li class="nav-item">
<a href="https://mailu.io" target="_blank" class="nav-link">
<li class="nav-item" role="none">
<a href="https://mailu.io" target="_blank" class="nav-link" role="menuitem" rel="noreferrer">
<i class="nav-icon fa fa-life-ring"></i>
<p class="text">{% trans %}Help{% endtrans %}</p>
<p>{% trans %}Help{% endtrans %} <i class="fas fa-external-link-alt text-xs"></i></p>
</a>
</li>
{% if config['DOMAIN_REGISTRATION'] %}
<li class="nav-item">
<a href="{{ url_for('.domain_signup') }}" class="nav-link">
{%- if config['DOMAIN_REGISTRATION'] %}
<li class="nav-item" role="none">
<a href="{{ url_for('.domain_signup') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-plus-square"></i>
<p class="text">{% trans %}Register a domain{% endtrans %}</p>
<p>{% trans %}Register a domain{% endtrans %}</p>
</a>
</li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="nav-item">
<a href="{{ url_for('.logout') }}" class="nav-link">
{%- endif %}
{%- if current_user.is_authenticated %}
<li class="nav-item" role="none">
<a href="{{ url_for('sso.logout') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-sign-out-alt"></i>
<p class="text">{% trans %}Sign out{% endtrans %}</p>
<p>{% trans %}Sign out{% endtrans %}</p>
</a>
</li>
{% else %}
<li class="nav-item">
<a href="{{ url_for('.login') }}" class="nav-link">
<li class="nav-item" role="none">
<a href="{{ url_for('sso.login') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-sign-in-alt"></i>
<p class="text">{% trans %}Sign in{% endtrans %}</p>
<p>{% trans %}Sign in{% endtrans %}</p>
</a>
</li>
{% if signup_domains %}
<li class="nav-item">
<a href="{{ url_for('.user_signup') }}" class="nav-link">
{%- if signup_domains %}
<li class="nav-item" role="none">
<a href="{{ url_for('.user_signup') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-user-plus"></i>
<p class="text">{% trans %}Sign up{% endtrans %}</p>
<p>{% trans %}Sign up{% endtrans %}</p>
</a>
</li>
{% endif %}
{% endif %}
{%- endif %}
{%- endif %}
</ul>
</nav>
</div>

@ -1,9 +1,9 @@
{% extends "form.html" %}
{%- extends "form.html" %}
{% block title %}
{%- block title %}
{% trans %}Create an authentication token{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ user }}
{% endblock %}
{%- endblock %}

@ -1,38 +1,40 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Authentication tokens{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ user }}
{% endblock %}
{%- endblock %}
{% block main_action %}
{%- block main_action %}
<a class="btn btn-primary float-right" href="{{ url_for('.token_create', user_email=user.email) }}">{% trans %}New token{% endtrans %}</a>
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<thead>
<tr>
<th>{% trans %}Actions{% endtrans %}</th>
<th>{% trans %}Comment{% endtrans %}</th>
<th>{% trans %}Authorized IP{% endtrans %}</th>
<th>{% trans %}Created{% endtrans %}</th>
<th>{% trans %}Last edit{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for token in user.tokens %}
{%- for token in user.tokens %}
<tr>
<td>
<a href="{{ url_for('.token_delete', token_id=token.id) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>
</td>
<td>{{ token.comment }}</td>
<td>{{ token.ip or "any" }}</td>
<td>{{ token.created_at }}</td>
<td>{{ token.created_at | format_date }}</td>
<td>{{ token.updated_at | format_date }}</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,33 +1,32 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}New user{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ domain.name }}
{% endblock %}
{%- endblock %}
{% block content %}
{%- block content %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{% call macros.card(_("General")) %}
{%- call macros.card(_("General")) %}
{{ macros.form_field(form.localpart, append='<span class="input-group-text">@'+domain.name+'</span>') }}
{{ macros.form_fields((form.pw, form.pw2)) }}
{{ macros.form_field(form.displayed_name) }}
{{ macros.form_field(form.comment) }}
{{ macros.form_field(form.enabled) }}
{% endcall %}
{%- endcall %}
{% call macros.card(_("Features and quotas"), theme="success") %}
{{ macros.form_field(form.quota_bytes, step=1000000000, max=(max_quota_bytes or domain.max_quota_bytes or 50000000000),
prepend='<span class="input-group-text"><span id="quota">'+((form.quota_bytes.data//1000000000).__str__() if form.quota_bytes.data else '∞')+'</span> GiB</span>',
oninput='$("#quota").text(this.value == 0 ? "∞" : this.value/1000000000);') }}
{%- call macros.card(_("Features and quotas"), theme="success") %}
{{ macros.form_field(form.quota_bytes, step=1000000000, max=(max_quota_bytes or domain.max_quota_bytes or 50*10**9), data_infinity="true",
prepend='<span class="input-group-text"><span id="quota_bytes_value"></span>&nbsp;GB</span>') }}
{{ macros.form_field(form.enable_imap) }}
{{ macros.form_field(form.enable_pop) }}
{% endcall %}
{%- endcall %}
{{ macros.form_field(form.submit) }}
</form>
{% endblock %}
{%- endblock %}

@ -1,9 +1,9 @@
{% extends "user/create.html" %}
{%- extends "user/create.html" %}
{% block title %}
{%- block title %}
{% trans %}Edit user{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ user }}
{% endblock %}
{%- endblock %}

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}
{% trans %}Forward emails{% endtrans %}
{% endblock %}
{% block subtitle %}
{{ user }}
{% endblock %}
{% block content %}
{% call macros.card() %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.forward_enabled,
onchange="if(this.checked){$('#forward_destination,#forward_keep').removeAttr('disabled')}
else{$('#forward_destination,#forward_keep').attr('disabled', '')}") }}
{{ macros.form_field(form.forward_keep,
**{("enabled" if user.forward_enabled else "disabled"): ""}) }}
{{ macros.form_field(form.forward_destination,
**{("enabled" if user.forward_enabled else "disabled"): ""}) }}
{{ macros.form_field(form.submit) }}
</form>
{% endcall %}
{% endblock %}

@ -1,19 +1,19 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}User list{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ domain.name }}
{% endblock %}
{%- endblock %}
{% block main_action %}
{%- block main_action %}
<a class="btn btn-primary float-right" href="{{ url_for('.user_create', domain_name=domain.name) }}">{% trans %}Add user{% endtrans %}</a>
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<thead>
<tr>
<th>{% trans %}Actions{% endtrans %}</th>
@ -27,8 +27,8 @@
</tr>
</thead>
<tbody>
{% for user in domain.users %}
<tr {% if not user.enabled %}class="warning"{% endif %}>
{%- for user in domain.users %}
<tr{% if not user.enabled %} class="warning"{% endif %}>
<td>
<a href="{{ url_for('.user_edit', user_email=user.email) }}" title="{% trans %}Edit{% endtrans %}"><i class="fas fa-pencil-alt"></i></a>&nbsp;
<a href="{{ url_for('.user_delete', user_email=user.email) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>
@ -45,10 +45,10 @@
</td>
<td>{{ user.quota_bytes_used | filesizeformat }} / {{ (user.quota_bytes | filesizeformat) if user.quota_bytes else '∞' }}</td>
<td>{{ user.comment or '-' }}</td>
<td>{{ user.created_at }}</td>
<td>{{ user.updated_at or '' }}</td>
<td>{{ user.created_at | format_date }}</td>
<td>{{ user.updated_at | format_date }}</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,9 +1,9 @@
{% extends "form.html" %}
{%- extends "form.html" %}
{% block title %}
{%- block title %}
{% trans %}Password update{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ user }}
{% endblock %}
{%- endblock %}

@ -1,30 +1,23 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Automatic reply{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ user }}
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.card() %}
{%- block content %}
{%- call macros.card() %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.reply_enabled,
onchange="if(this.checked){$('#reply_subject,#reply_body,#reply_enddate,#reply_startdate').removeAttr('readonly')}
else{$('#reply_subject,#reply_body,#reply_enddate,#reply_startdate').attr('readonly', '')}") }}
{{ macros.form_field(form.reply_subject,
**{("rw" if user.reply_enabled else "readonly"): ""}) }}
{{ macros.form_field(form.reply_body, rows=10,
**{("rw" if user.reply_enabled else "readonly"): ""}) }}
{{ macros.form_field(form.reply_enddate,
**{("rw" if user.reply_enabled else "readonly"): ""}) }}
{{ macros.form_field(form.reply_startdate,
**{("rw" if user.reply_enabled else "readonly"): ""}) }}
{%- call macros.fieldset(
field=form.reply_enabled,
enabled=user.reply_enabled,
fields=[form.reply_subject, form.reply_body, form.reply_enddate, form.reply_startdate]) %}
{%- endcall %}
{{ macros.form_field(form.submit) }}
</form>
{% endcall %}
{% endblock %}
{%- endcall %}
{%- endblock %}

@ -1,38 +1,36 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}User settings{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ user }}
{% endblock %}
{%- endblock %}
{% block content %}
{%- block content %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{% call macros.card(title=_("Displayed name")) %}
{%- call macros.card(title=_("Displayed name")) %}
{{ macros.form_field(form.displayed_name) }}
{% endcall %}
{%- endcall %}
{% call macros.card(title=_("Antispam")) %}
{{ macros.form_field(form.spam_enabled) }}
{%- call macros.card(title=_("Antispam")) %}
{%- call macros.fieldset(field=form.spam_enabled, enabled=user.spam_enabled) %}
{{ macros.form_field(form.spam_threshold, step=1, max=100,
prepend='<span class="input-group-text"><span id="threshold">'+form.spam_threshold.data.__str__()+'</span>&nbsp;/&nbsp;100</span>',
oninput='$("#threshold").text(this.value);') }}
{% endcall %}
prepend='<span class="input-group-text"><span id="spam_threshold_value"></span>&nbsp;/&nbsp;100</span>') }}
{%- endcall %}
{%- endcall %}
{% call macros.card(title=_("Auto-forward")) %}
{{ macros.form_field(form.forward_enabled,
onchange="if(this.checked){$('#forward_destination,#forward_keep').removeAttr('disabled')}
else{$('#forward_destination,#forward_keep').attr('disabled', '')}") }}
{{ macros.form_field(form.forward_keep,
**{("enabled" if user.forward_enabled else "disabled"): ""}) }}
{{ macros.form_field(form.forward_destination,
**{("enabled" if user.forward_enabled else "disabled"): ""}) }}
{% endcall %}
{%- call macros.card(title=_("Auto-forward")) %}
{%- call macros.fieldset(
field=form.forward_enabled,
enabled=user.forward_enabled,
fields=[form.forward_keep, form.forward_destination]) %}
{%- endcall %}
{%- endcall %}
{{ macros.form_field(form.submit) }}
</form>
{% endblock %}
{%- endblock %}

@ -1,23 +1,23 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Sign up{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{{ domain }}
{% endblock %}
{%- endblock %}
{% block content %}
{%- block content %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{% call macros.card() %}
{%- call macros.card() %}
{{ macros.form_field(form.localpart, append='<span class="input-group-text">@'+domain.name+'</span>') }}
{{ macros.form_fields((form.pw, form.pw2)) }}
{% if form.captcha %}
{%- if form.captcha %}
{{ macros.form_field(form.captcha) }}
{% endif %}
{%- endif %}
{{ macros.form_field(form.submit) }}
{% endcall %}
{%- endcall %}
</form>
{% endblock %}
{%- endblock %}

@ -1,26 +1,26 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block title %}
{%- block title %}
{% trans %}Sign up{% endtrans %}
{% endblock %}
{%- endblock %}
{% block subtitle %}
{%- block subtitle %}
{% trans %}pick a domain for the new account{% endtrans %}
{% endblock %}
{%- endblock %}
{% block content %}
{% call macros.table() %}
{%- block content %}
{%- call macros.table() %}
<tr>
<th>{% trans %}Domain{% endtrans %}</th>
<th>{% trans %}Available slots{% endtrans %}</th>
<th>{% trans %}Quota{% endtrans %}</th>
</tr>
{% for domain_name, domain in available_domains.items() %}
{%- for domain_name, domain in available_domains.items() %}
<tr>
<td><a href="{{ url_for('.user_signup', domain_name=domain_name) }}">{{ domain_name }}</a></td>
<td>{{ '∞' if domain.max_users == -1 else domain.max_users - (domain.users | count)}}</td>
<td>{{ domain.max_quota_bytes or config['DEFAULT_QUOTA'] | filesizeformat }}</td>
</tr>
{% endfor %}
{% endcall %}
{% endblock %}
{%- endfor %}
{%- endcall %}
{%- endblock %}

@ -1,5 +1,5 @@
{% extends "base.html" %}
{%- extends "base.html" %}
{% block content %}
{%- block content %}
<div class="alert alert-warning" role="alert">{% trans %}We are still working on this feature!{% endtrans %}</div>
{% endblock %}
{%- endblock %}

@ -1,4 +1,4 @@
from mailu import models
from mailu import models, utils
from mailu.ui import ui, forms, access
from flask import current_app as app
@ -11,31 +11,6 @@ import flask_login
def index():
return flask.redirect(flask.url_for('.user_settings'))
@ui.route('/login', methods=['GET', 'POST'])
def login():
form = forms.LoginForm()
if form.validate_on_submit():
user = models.User.login(form.email.data, form.pw.data)
if user:
flask.session.regenerate()
flask_login.login_user(user)
endpoint = flask.request.args.get('next', '.index')
return flask.redirect(flask.url_for(endpoint)
or flask.url_for('.index'))
else:
flask.flash('Wrong e-mail or password', 'error')
return flask.render_template('login.html', form=form)
@ui.route('/logout', methods=['GET'])
@access.authenticated
def logout():
flask_login.logout_user()
flask.session.destroy()
return flask.redirect(flask.url_for('.index'))
@ui.route('/announcement', methods=['GET', 'POST'])
@access.global_admin
def announcement():
@ -57,3 +32,7 @@ def webmail():
@ui.route('/client', methods=['GET'])
def client():
return flask.render_template('client.html')
@ui.route('/webui_antispam', methods=['GET'])
def antispam():
return flask.render_template('antispam.html')

@ -5,7 +5,6 @@ from flask import current_app as app
import flask
import flask_login
import wtforms_components
import dns.resolver
@ui.route('/domain', methods=['GET'])

@ -2,8 +2,8 @@ from mailu.ui import ui, forms, access
import flask
@ui.route('/language/<language>', methods=['GET'])
@ui.route('/language/<language>', methods=['POST'])
def set_language(language=None):
flask.session['language'] = language
return flask.redirect(flask.url_for('.user_settings'))
return flask.Response(status=200)

@ -129,23 +129,6 @@ def user_password(user_email):
return flask.render_template('user/password.html', form=form, user=user)
@ui.route('/user/forward', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/user/forward/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email')
def user_forward(user_email):
user_email_or_current = user_email or flask_login.current_user.email
user = models.User.query.get(user_email_or_current) or flask.abort(404)
form = forms.UserForwardForm(obj=user)
if form.validate_on_submit():
form.populate_obj(user)
models.db.session.commit()
flask.flash('Forward destination updated for %s' % user)
if user_email:
return flask.redirect(
flask.url_for('.user_list', domain_name=user.domain.name))
return flask.render_template('user/forward.html', form=form, user=user)
@ui.route('/user/reply', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/user/reply/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email')

@ -6,55 +6,96 @@ try:
except ImportError:
import pickle
import dns.resolver
import dns.exception
import dns.flags
import dns.rdtypes
import dns.rdatatype
import dns.rdataclass
import hmac
import secrets
import time
from multiprocessing import Value
from mailu import limiter
from flask import current_app as app
import flask
import flask_login
import flask_migrate
import flask_babel
import ipaddress
import redis
from flask.sessions import SessionMixin, SessionInterface
from itsdangerous.encoding import want_bytes
from werkzeug.datastructures import CallbackDict
from werkzeug.contrib import fixers
from werkzeug.middleware.proxy_fix import ProxyFix
# Login configuration
login = flask_login.LoginManager()
login.login_view = "ui.login"
login.login_view = "sso.login"
@login.unauthorized_handler
def handle_needs_login():
""" redirect unauthorized requests to login page """
return flask.redirect(
flask.url_for('ui.login', next=flask.request.endpoint)
flask.url_for('sso.login')
)
# DNS stub configured to do DNSSEC enabled queries
resolver = dns.resolver.Resolver()
resolver.use_edns(0, 0, 1232)
resolver.flags = dns.flags.AD | dns.flags.RD
def has_dane_record(domain, timeout=10):
try:
result = resolver.query(f'_25._tcp.{domain}', dns.rdatatype.TLSA,dns.rdataclass.IN, lifetime=timeout)
if result.response.flags & dns.flags.AD:
for record in result:
if isinstance(record, dns.rdtypes.ANY.TLSA.TLSA):
record.validate()
if record.usage in [2,3] and record.selector in [0,1] and record.mtype in [0,1,2]:
return True
except dns.resolver.NoNameservers:
# If the DNSSEC data is invalid and the DNS resolver is DNSSEC enabled
# we will receive this non-specific exception. The safe behaviour is to
# accept to defer the email.
app.logger.warn(f'Unable to lookup the TLSA record for {domain}. Is the DNSSEC zone okay on https://dnsviz.net/d/{domain}/dnssec/?')
return app.config['DEFER_ON_TLS_ERROR']
except dns.exception.Timeout:
app.logger.warn(f'Timeout while resolving the TLSA record for {domain} ({timeout}s).')
except dns.resolver.NXDOMAIN:
pass # this is expected, not TLSA record is fine
except Exception as e:
app.logger.error(f'Error while looking up the TLSA record for {domain} {e}')
pass
# Rate limiter
limiter = limiter.LimitWraperFactory()
def extract_network_from_ip(ip):
n = ipaddress.ip_network(ip)
if n.version == 4:
return str(n.supernet(prefixlen_diff=(32-int(app.config["AUTH_RATELIMIT_IP_V4_MASK"]))).network_address)
else:
return str(n.supernet(prefixlen_diff=(128-int(app.config["AUTH_RATELIMIT_IP_V6_MASK"]))).network_address)
def is_exempt_from_ratelimits(ip):
ip = ipaddress.ip_address(ip)
return any(ip in cidr for cidr in app.config['AUTH_RATELIMIT_EXEMPTION'])
# Application translation
babel = flask_babel.Babel()
@babel.localeselector
def get_locale():
""" selects locale for translation """
translations = list(map(str, babel.list_translations()))
flask.session['available_languages'] = translations
try:
language = flask.session['language']
except KeyError:
language = flask.request.accept_languages.best_match(translations)
language = flask.session.get('language')
if not language in app.config.translations:
language = flask.request.accept_languages.best_match(app.config.translations.keys())
flask.session['language'] = language
return language
@ -65,13 +106,10 @@ class PrefixMiddleware(object):
self.app = None
def __call__(self, environ, start_response):
prefix = environ.get('HTTP_X_FORWARDED_PREFIX', '')
if prefix:
environ['SCRIPT_NAME'] = prefix
return self.app(environ, start_response)
def init_app(self, app):
self.app = fixers.ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
self.app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
app.wsgi_app = self
proxy = PrefixMiddleware()
@ -230,7 +268,7 @@ class MailuSession(CallbackDict, SessionMixin):
# set uid from dict data
if self._uid is None:
self._uid = self.app.session_config.gen_uid(self.get('user_id', ''))
self._uid = self.app.session_config.gen_uid(self.get('_user_id', ''))
# create new session id for new or regenerated sessions and force setting the cookie
if self._sid is None:
@ -450,7 +488,7 @@ class MailuSessionExtension:
with cleaned.get_lock():
if not cleaned.value:
cleaned.value = True
flask.current_app.logger.error('cleaning')
app.logger.info('cleaning session store')
MailuSessionExtension.cleanup_sessions(app)
app.before_first_request(cleaner)

@ -1,10 +1,12 @@
from __future__ import with_statement
import logging
import tenacity
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import logging
import tenacity
from tenacity import retry
from flask import current_app
from mailu import models
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@ -17,34 +19,26 @@ logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
#target_metadata = current_app.extensions['migrate'].db.metadata
from mailu import models
config.set_main_option(
'sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI')
)
target_metadata = models.Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
url = config.get_main_option('sqlalchemy.url')
context.configure(url=url)
with context.begin_transaction():
@ -69,28 +63,35 @@ def run_migrations_online():
directives[:] = []
logger.info('No changes in schema detected.')
engine = engine_from_config(config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
engine = engine_from_config(
config.get_section(config.config_ini_section),
prefix = 'sqlalchemy.',
poolclass = pool.NullPool
)
connection = tenacity.Retrying(
stop=tenacity.stop_after_attempt(100),
wait=tenacity.wait_random(min=2, max=5),
before=tenacity.before_log(logging.getLogger("tenacity.retry"), logging.DEBUG),
before_sleep=tenacity.before_sleep_log(logging.getLogger("tenacity.retry"), logging.INFO),
after=tenacity.after_log(logging.getLogger("tenacity.retry"), logging.DEBUG)
).call(engine.connect)
@tenacity.retry(
stop = tenacity.stop_after_attempt(100),
wait = tenacity.wait_random(min=2, max=5),
before = tenacity.before_log(logging.getLogger('tenacity.retry'), logging.DEBUG),
before_sleep = tenacity.before_sleep_log(logging.getLogger('tenacity.retry'), logging.INFO),
after = tenacity.after_log(logging.getLogger('tenacity.retry'), logging.DEBUG)
)
def try_connect(db):
return db.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)
with try_connect(engine) as connection:
context.configure(
connection = connection,
target_metadata = target_metadata,
process_revision_directives = process_revision_directives,
**current_app.extensions['migrate'].configure_args
)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
connection.close()
if context.is_offline_mode():
run_migrations_offline()

@ -12,20 +12,19 @@
"author": "",
"license": "ISC",
"dependencies": {
"@babel/core": "^7.15.0",
"@babel/preset-env": "^7.15.0",
"admin-lte": "^3.1.0",
"babel-loader": "^8.2.2",
"clipboard": "^2.0.8",
"compression-webpack-plugin": "^8.0.1",
"css-loader": "^6.2.0",
"expose-loader": "^3.0.0",
"jquery": "^3.6.0",
"less": "^4.1.1",
"css-minimizer-webpack-plugin": "^3.0.2",
"datatables.net-plugins": "^1.10.24",
"import-glob": "^1.5.0",
"less-loader": "^10.0.1",
"mini-css-extract-plugin": "^2.2.0",
"node-sass": "^6.0.1",
"sass": "<1.33.0",
"sass-loader": "^12.1.0",
"select2": "^4.0.13",
"webpack": "^5.50.0",
"webpack-cli": "^4.7.2"
"terser-webpack-plugin": "^5.2.0",
"webpack-cli": "^4.8.0"
}
}

@ -1,56 +1,75 @@
alembic==1.0.10
asn1crypto==0.24.0
Babel==2.6.0
bcrypt==3.1.6
alembic==1.7.4
appdirs==1.4.4
Babel==2.9.1
bcrypt==3.2.0
blinker==1.4
cffi==1.12.3
Click==7.0
cryptography==3.4.7
decorator==4.4.0
dnspython==1.16.0
dominate==2.3.5
Flask==1.0.2
Flask-Babel==0.12.2
CacheControl==0.12.9
certifi==2021.10.8
cffi==1.15.0
chardet==4.0.0
click==8.0.3
colorama==0.4.4
contextlib2==21.6.0
cryptography==35.0.0
decorator==5.1.0
# distlib==0.3.1
# distro==1.5.0
dnspython==2.1.0
dominate==2.6.0
email-validator==1.1.3
Flask==2.0.2
Flask-Babel==2.0.0
Flask-Bootstrap==3.3.7.1
Flask-DebugToolbar==0.10.1
Flask-Limiter==1.0.1
Flask-Login==0.4.1
Flask-DebugToolbar==0.11.0
Flask-Limiter==1.4
Flask-Login==0.5.0
flask-marshmallow==0.14.0
Flask-Migrate==2.4.0
Flask-Migrate==3.1.0
Flask-Script==2.0.6
Flask-SQLAlchemy==2.4.0
Flask-WTF==0.14.2
gunicorn==19.9.0
idna==2.8
infinity==1.4
intervals==0.8.1
itsdangerous==1.1.0
Jinja2==2.11.3
limits==1.3
Mako==1.0.9
MarkupSafe==1.1.1
mysqlclient==1.4.2.post1
marshmallow==3.10.0
marshmallow-sqlalchemy==0.24.1
Flask-SQLAlchemy==2.5.1
Flask-WTF==0.15.1
greenlet==1.1.2
gunicorn==20.1.0
html5lib==1.1
idna==3.3
infinity==1.5
intervals==0.9.2
itsdangerous==2.0.1
Jinja2==3.0.2
limits==1.5.1
lockfile==0.12.2
Mako==1.1.5
MarkupSafe==2.0.1
marshmallow==3.14.0
marshmallow-sqlalchemy==0.26.1
msgpack==1.0.2
mysqlclient==2.0.3
ordered-set==4.0.2
# packaging==20.9
passlib==1.7.4
psycopg2==2.8.2
pycparser==2.19
Pygments==2.8.1
pyOpenSSL==20.0.1
python-dateutil==2.8.0
python-editor==1.0.4
pytz==2019.1
PyYAML==5.4.1
redis==3.2.1
#alpine3:12 provides six==1.15.0
#six==1.12.0
socrate==0.1.1
SQLAlchemy==1.3.3
# pep517==0.10.0
progress==1.6
psycopg2==2.9.1
pycparser==2.20
Pygments==2.10.0
pyOpenSSL==21.0.0
pyparsing==3.0.4
pytz==2021.3
PyYAML==6.0
redis==3.5.3
requests==2.26.0
retrying==1.3.3
# six==1.15.0
socrate==0.2.0
SQLAlchemy==1.4.26
srslib==0.1.4
tabulate==0.8.3
tenacity==5.0.4
validators==0.12.6
tabulate==0.8.9
tenacity==8.0.1
toml==0.10.2
urllib3==1.26.7
validators==0.18.2
visitor==0.1.3
Werkzeug==0.15.5
WTForms==2.2.1
WTForms-Components==0.10.4
webencodings==0.5.1
Werkzeug==2.0.2
WTForms==2.3.3
WTForms-Components==0.10.5

@ -18,10 +18,8 @@ PyYAML
PyOpenSSL
Pygments
dnspython
bcrypt
tenacity
mysqlclient
psycopg2
idna
srslib
marshmallow

@ -1,65 +1,76 @@
var path = require("path");
var webpack = require("webpack");
var css = require("mini-css-extract-plugin");
const path = require('path');
const webpack = require('webpack');
const css = require('mini-css-extract-plugin');
const mini = require('css-minimizer-webpack-plugin');
const terse = require('terser-webpack-plugin');
const compress = require('compression-webpack-plugin');
module.exports = {
mode: "development",
mode: 'production',
entry: {
app: "./assets/app.js",
vendor: "./assets/vendor.js"
app: {
import: './assets/app.js',
dependOn: 'vendor',
},
vendor: './assets/vendor.js',
},
output: {
path: path.resolve(__dirname, "static/"),
filename: "[name].js"
path: path.resolve(__dirname, 'static/'),
filename: '[name].js',
assetModuleFilename: '[name][ext]',
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader']
use: ['babel-loader', 'import-glob'],
},
{
test: /\.scss$/,
use: [css.loader, 'css-loader', 'sass-loader']
test: /\.s?css$/i,
use: [css.loader, 'css-loader', 'sass-loader'],
},
{
test: /\.less$/,
use: [css.loader, 'css-loader', 'less-loader']
test: /\.less$/i,
use: [css.loader, 'css-loader', 'less-loader'],
},
{
test: /\.css$/,
use: [css.loader, 'css-loader']
test: /\.(json|png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
{
// Exposes jQuery for use outside Webpack build
test: require.resolve('jquery'),
use: [{
loader: 'expose-loader',
options: {
exposes: [
{
globalName: '$',
override: true,
},
{
globalName: 'jQuery',
override: true,
},
]
},
}]
}
]
],
},
plugins: [
new css({
filename: "[name].css",
chunkFilename: "[id].css"
}),
new css({
filename: '[name].css',
chunkFilename: '[id].css',
}),
new webpack.ProvidePlugin({
$: "jquery",
jQuery: "jquery"
})
]
}
$: 'jquery',
jQuery: 'jquery',
ClipboardJS: 'clipboard',
}),
new compress({
filename: '[path][base].gz',
algorithm: "gzip",
exclude: /\.(png|gif|jpe?g)$/,
threshold: 5120,
minRatio: 0.8,
deleteOriginalAssets: false,
}),
],
optimization: {
minimize: true,
minimizer: [
new terse(),
new mini({
minimizerOptions: {
preset: [
'default', {
discardComments: { removeAll: true },
},
],
},
}),
],
},
};

@ -1,4 +1,4 @@
ARG DISTRO=alpine:3.14
ARG DISTRO=alpine:3.14.2
FROM $DISTRO as builder
WORKDIR /tmp
RUN apk add git build-base automake autoconf libtool dovecot-dev xapian-core-dev icu-dev
@ -11,9 +11,12 @@ RUN git clone https://github.com/grosjo/fts-xapian.git \
&& make install
FROM $DISTRO
ENV TZ Etc/UTC
# python3 shared with most images
RUN apk add --no-cache \
python3 py3-pip git bash py3-multidict py3-yarl \
python3 py3-pip git bash py3-multidict py3-yarl tzdata \
&& pip3 install --upgrade pip
# Shared layer between nginx, dovecot, postfix, postgresql, rspamd, unbound, rainloop, roundcube

@ -6,7 +6,7 @@ import multiprocessing
import logging as log
import sys
from podop import run_server
from podop import run_server
from socrate import system, conf
log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "WARNING"))

@ -1,5 +1,8 @@
ARG DISTRO=alpine:3.14
ARG DISTRO=alpine:3.14.2
FROM $DISTRO
ENV TZ Etc/UTC
# python3 shared with most images
RUN apk add --no-cache \
python3 py3-pip git bash py3-multidict \
@ -9,13 +12,15 @@ RUN apk add --no-cache \
RUN pip3 install socrate==0.2.0
# Image specific layers under this line
RUN apk add --no-cache certbot nginx nginx-mod-mail openssl curl \
RUN apk add --no-cache certbot nginx nginx-mod-mail openssl curl tzdata \
&& pip3 install watchdog
COPY conf /conf
COPY static /static
COPY *.py /
RUN gzip -k9 /static/*.ico /static/*.txt; chmod a+rX -R /static
EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp 10025/tcp 10143/tcp
VOLUME ["/certs"]
VOLUME ["/overrides"]

@ -1,24 +1,23 @@
# Basic configuration
# Basic configuration
user nginx;
worker_processes auto;
error_log /dev/stderr info;
error_log /dev/stderr notice;
pid /var/run/nginx.pid;
load_module "modules/ngx_mail_module.so";
events {
worker_connections 1024;
worker_connections 1024;
}
http {
# Standard HTTP configuration with slight hardening
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
keepalive_timeout 65;
keepalive_timeout 65;
server_tokens off;
absolute_redirect off;
resolver {{ RESOLVER }} valid=30s;
resolver {{ RESOLVER }} ipv6=off valid=30s;
{% if REAL_IP_HEADER %}
real_ip_header {{ REAL_IP_HEADER }};
@ -33,6 +32,24 @@ http {
default $http_x_forwarded_proto;
'' $scheme;
}
map $uri $expires {
default off;
~*\.(ico|css|js|gif|jpeg|jpg|png|woff2?|ttf|otf|svg|tiff|eot|webp)$ 97d;
}
map $request_uri $loggable {
/health 0;
/auth/email 0;
default 1;
}
access_log /dev/stdout combined if=$loggable;
# compression
gzip on;
gzip_static on;
gzip_types text/plain text/css application/xml application/javascript
gzip_min_length 1024;
# TODO: figure out how to server pre-compressed assets from admin container
{% if KUBERNETES_INGRESS != 'true' and TLS_FLAVOR in [ 'letsencrypt', 'cert' ] %}
# Enable the proxy for certbot if the flavor is letsencrypt and not on kubernetes
@ -50,6 +67,7 @@ http {
location / {
return 301 https://$host$request_uri;
}
}
{% endif %}
@ -68,7 +86,7 @@ http {
{% endif %}
# Listen on HTTP only in kubernetes or behind reverse proxy
{% if KUBERNETES_INGRESS == 'true' or TLS_FLAVOR in [ 'mail-letsencrypt', 'notls', 'mail' ] %}
{% if KUBERNETES_INGRESS == 'true' or TLS_FLAVOR in [ 'mail-letsencrypt', 'notls', 'mail' ] %}
listen 80;
listen [::]:80;
{% endif %}
@ -113,12 +131,19 @@ http {
return 403;
}
{% else %}
include /overrides/*.conf;
# Actual logic
{% if ADMIN == 'true' or WEBMAIL != 'none' %}
location ~ ^/(sso|static) {
include /etc/nginx/proxy.conf;
proxy_pass http://$admin;
}
{% endif %}
{% if WEB_WEBMAIL != '/' and WEBROOT_REDIRECT != 'none' %}
location / {
expires $expires;
{% if WEBROOT_REDIRECT %}
try_files $uri {{ WEBROOT_REDIRECT }};
{% else %}
@ -135,10 +160,9 @@ http {
{% endif %}
include /etc/nginx/proxy.conf;
client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }};
proxy_pass http://$webmail;
{% if ADMIN == 'true' %}
auth_request /internal/auth/user;
error_page 403 @webmail_login;
proxy_pass http://$webmail;
}
location {{ WEB_WEBMAIL }}/sso.php {
@ -153,27 +177,20 @@ http {
auth_request_set $token $upstream_http_x_user_token;
proxy_set_header X-Remote-User $user;
proxy_set_header X-Remote-User-Token $token;
proxy_pass http://$webmail;
error_page 403 @webmail_login;
proxy_pass http://$webmail;
}
location @webmail_login {
return 302 {{ WEB_ADMIN }}/ui/login?next=ui.webmail;
return 302 /sso/login;
}
{% else %}
}
{% endif %}{% endif %}
{% endif %}
{% if ADMIN == 'true' %}
location {{ WEB_ADMIN }} {
return 301 {{ WEB_ADMIN }}/ui;
}
location ~ {{ WEB_ADMIN }}/(ui|static) {
rewrite ^{{ WEB_ADMIN }}/(.*) /$1 break;
include /etc/nginx/proxy.conf;
proxy_set_header X-Forwarded-Prefix {{ WEB_ADMIN }};
proxy_pass http://$admin;
}
location {{ WEB_ADMIN }} {
include /etc/nginx/proxy.conf;
proxy_pass http://$admin;
expires $expires;
}
location {{ WEB_ADMIN }}/antispam {
rewrite ^{{ WEB_ADMIN }}/antispam/(.*) /$1 break;
@ -204,6 +221,7 @@ http {
location /internal {
internal;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
proxy_pass http://$admin;
@ -233,7 +251,8 @@ mail {
server_name {{ HOSTNAMES.split(",")[0] }};
auth_http http://127.0.0.1:8000/auth/email;
proxy_pass_error_message on;
resolver {{ RESOLVER }} valid=30s;
resolver {{ RESOLVER }} ipv6=off valid=30s;
error_log /dev/stderr info;
{% if TLS and not TLS_ERROR %}
include /etc/nginx/tls.conf;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 B

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

@ -1,6 +1,6 @@
# This is an idle image to dynamically replace any component if disabled.
ARG DISTRO=alpine:3.14
ARG DISTRO=alpine:3.14.2
FROM $DISTRO
CMD sleep 1000000d

@ -1,8 +1,11 @@
ARG DISTRO=alpine:3.14
ARG DISTRO=alpine:3.14.2
FROM $DISTRO
ENV TZ Etc/UTC
# python3 shared with most images
RUN apk add --no-cache \
python3 py3-pip git bash py3-multidict py3-yarl \
python3 py3-pip git bash py3-multidict py3-yarl tzdata \
&& pip3 install --upgrade pip
# Shared layer between nginx, dovecot, postfix, postgresql, rspamd, unbound, rainloop, roundcube
@ -12,6 +15,10 @@ RUN pip3 install socrate==0.2.0
RUN pip3 install "podop>0.2.5"
# Image specific layers under this line
RUN apk add --no-cache --virtual .build-deps gcc musl-dev python3-dev
RUN pip3 install --no-binary :all: postfix-mta-sts-resolver==1.0.1
RUN apk del .build-deps gcc musl-dev python3-dev
RUN apk add --no-cache postfix postfix-pcre cyrus-sasl-login
COPY conf /conf

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save