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

master
Alexander Graf 3 years ago
commit 46d27e48ff

@ -38,7 +38,7 @@ RUN set -eu \
&& pip3 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
@ -51,4 +51,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

@ -43,7 +43,7 @@ $('document').ready(function() {
var infinity = $(this).data('infinity');
var step = $(this).attr('step');
$(this).on('input', function() {
value_element.text((infinity && this.value == 0) ? '∞' : this.value/step);
value_element.text((infinity && this.value == 0) ? '∞' : (this.value/step).toFixed(2));
}).trigger('input');
}
});

@ -11,7 +11,7 @@ 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 error display and flash messages
@ -58,10 +58,10 @@ def create_app_from_config(config):
)
# Import views
from mailu import ui, internal
app.register_blueprint(ui.ui, url_prefix='/ui')
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
@ -70,3 +70,4 @@ def create_app():
"""
config = configuration.ConfigManager()
return create_app_from_config(config)

@ -58,6 +58,7 @@ DEFAULT_CONFIG = {
# Web settings
'SITENAME': 'Mailu',
'WEBSITE': 'https://mailu.io',
'ADMIN' : 'none',
'WEB_ADMIN': '/admin',
'WEB_WEBMAIL': '/webmail',
'WEBMAIL': 'none',

@ -78,12 +78,6 @@ def handle_authentication(headers):
# Authenticated user
elif method == "plain":
is_valid_user = False
if headers["Auth-Port"] == '25':
return {
"Auth-Status": "AUTH not supported",
"Auth-Error-Code": "502 5.5.1",
"Auth-Wait": 0
}
# 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

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

@ -11,6 +11,13 @@ def nginx_authentication():
""" Main authentication endpoint for Nginx email server
"""
client_ip = flask.request.headers["Client-Ip"]
headers = flask.request.headers
if headers["Auth-Port"] == '25' and headers['Auth-Method'] == 'plain':
response = flask.Response()
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()
@ -41,7 +48,7 @@ def nginx_authentication():
elif is_valid_user:
utils.limiter.rate_limit_user(username, client_ip)
else:
rate_limit_ip(client_ip)
utils.limiter.rate_limit_ip(client_ip)
return response
@internal.route("/auth/admin")

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

@ -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", spacing=False) }}
</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,56 @@
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)
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'))
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)

@ -551,11 +551,11 @@ msgid ""
"cache\n"
" expires."
msgstr ""
"Jeśli nie wiesz, jak skonfigurować rekord <code> MX </code> dla swojej "
"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 "
"kilka minut po ustawieniu <code> MX </code>, żeby pamięć podręczna "
"serwera lokalnego wygasła."
#: mailu/ui/templates/fetch/create.html:4

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

@ -1,15 +1,15 @@
{%- import "macros.html" as macros %}
{%- import "bootstrap/utils.html" as utils %}
<!doctype html>
<html lang="{{ session['language'] }}" data-static="{{ url_for('.static', filename='') }}">
<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') }}">
<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">
@ -38,7 +38,7 @@
<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.language == session['language'] %} active{% endif %}" href="{{ url_for('.set_language', language=locale.language) }}">{{ locale.get_language_name().title() }}</a>
<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>
@ -46,7 +46,7 @@
</nav>
<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">
<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>
{%- include "sidebar.html" %}
@ -80,7 +80,7 @@
</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,18 +0,0 @@
{%- extends "form.html" %}
{%- block title %}
{% trans %}Sign in{% endtrans %}
{%- endblock %}
{%- block subtitle %}
{% trans %}to access the administration tools{% endtrans %}
{%- endblock %}
{%- block content %}
{% if config["SESSION_COOKIE_SECURE"] %}
<div id="login_needs_https" class="alert alert-danger d-none" role="alert">
{% trans %}The login form has been disabled as <b>SESSION_COOKIE_SECURE</b> is on but you are accessing Mailu over HTTP.{% endtrans %}
</div>
{% endif %}
{{ super() }}
{%- endblock %}

@ -18,8 +18,12 @@
{%- endif %}
{%- endmacro %}
{%- macro form_fields(fields, prepend='', append='', label=True) %}
{%- macro form_fields(fields, prepend='', append='', label=True, spacing=True) %}
{%- if spacing %}
{%- set width = (12 / fields|length)|int %}
{%- else %}
{%- set width = 0 %}
{% endif %}
<div class="form-group">
<div class="row">
{%- for field in fields %}
@ -54,7 +58,7 @@
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{%- for field in form %}
{%- if bootstrap_is_hidden_field(field) %}
{%- if bootstrap_is_hidden_field(field) %}
{{ field() }}
{%- else %}
{{ form_field(field) }}

@ -92,7 +92,7 @@
{%- endif %}
<li class="nav-header text-uppercase text-primary" role="none">{% trans %}Go to{% endtrans %}</li>
{%- if config["WEBMAIL"] != "none" %}
{%- 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>
@ -130,14 +130,14 @@
{%- endif %}
{%- if current_user.is_authenticated %}
<li class="nav-item" role="none">
<a href="{{ url_for('.logout') }}" class="nav-link" role="menuitem">
<a href="{{ url_for('sso.logout') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-sign-out-alt"></i>
<p>{% trans %}Sign out{% endtrans %}</p>
</a>
</li>
{%- else %}
{% else %}
<li class="nav-item" role="none">
<a href="{{ url_for('.login') }}" class="nav-link" role="menuitem">
<a href="{{ url_for('sso.login') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-sign-in-alt"></i>
<p>{% trans %}Sign in{% endtrans %}</p>
</a>

@ -11,45 +11,6 @@ import flask_login
def index():
return flask.redirect(flask.url_for('.user_settings'))
@ui.route('/login', methods=['GET', 'POST'])
def login():
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
form = forms.LoginForm()
if form.validate_on_submit():
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)
endpoint = flask.request.args.get('next', '.index')
response = flask.redirect(flask.url_for(endpoint)
or flask.url_for('.index'))
response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('ui.login'))
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)
@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():
@ -72,7 +33,6 @@ def webmail():
def client():
return flask.render_template('client.html')
@ui.route('/antispam', methods=['GET'])
@ui.route('/webui_antispam', methods=['GET'])
def antispam():
return flask.render_template('antispam.html')

@ -32,13 +32,13 @@ from werkzeug.contrib import fixers
# 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
@ -103,9 +103,6 @@ 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):

@ -37,7 +37,7 @@ def run_migrations_offline():
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

@ -4,7 +4,7 @@ Mailu Dovecot container
Dovecot is an open source IMAP and POP3 email server for Linux/UNIX-like
systems, written with security primarily in mind. It's fast, simple to set
up, requires no special administration and it uses very little memory.
In the Mailu stack it is used as the IMAP/POP frontend service.
Resources

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

@ -16,7 +16,7 @@ COPY conf /conf
COPY static /static
COPY *.py /
RUN gzip -k9 /static/*.ico /static/*.txt
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"]

@ -1,21 +1,20 @@
# 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 }} ipv6=off valid=30s;
@ -38,6 +37,13 @@ http {
~*\.(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;
@ -47,12 +53,12 @@ http {
{% if KUBERNETES_INGRESS != 'true' and TLS_FLAVOR in [ 'letsencrypt', 'cert' ] %}
# Enable the proxy for certbot if the flavor is letsencrypt and not on kubernetes
#
#
server {
# Listen over HTTP
listen 80;
listen [::]:80;
{% if TLS_FLAVOR == 'letsencrypt' %}
{% if TLS_FLAVOR == 'letsencrypt' %}
location ^~ /.well-known/acme-challenge/ {
proxy_pass http://127.0.0.1:8008;
}
@ -80,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,7 +119,7 @@ http {
add_header X-XSS-Protection '1; mode=block';
add_header Referrer-Policy 'same-origin';
{% if TLS_FLAVOR == 'mail-letsencrypt' %}
{% if TLS_FLAVOR == 'mail-letsencrypt' %}
location ^~ /.well-known/acme-challenge/ {
proxy_pass http://127.0.0.1:8008;
}
@ -128,6 +134,13 @@ http {
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;
@ -147,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 {
@ -165,28 +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;
expires $expires;
}
location {{ WEB_ADMIN }} {
include /etc/nginx/proxy.conf;
proxy_pass http://$admin;
expires $expires;
}
location {{ WEB_ADMIN }}/antispam {
rewrite ^{{ WEB_ADMIN }}/antispam/(.*) /$1 break;
@ -248,6 +252,7 @@ mail {
auth_http http://127.0.0.1:8000/auth/email;
proxy_pass_error_message on;
resolver {{ RESOLVER }} ipv6=off valid=30s;
error_log /dev/stderr info;
{% if TLS and not TLS_ERROR %}
include /etc/nginx/tls.conf;

@ -17,7 +17,7 @@ queue_directory = /queue
message_size_limit = {{ MESSAGE_SIZE_LIMIT }}
# Relayed networks
mynetworks = 127.0.0.1/32 [::1]/128 {{ SUBNET }} {{ RELAYNETS }}
mynetworks = 127.0.0.1/32 [::1]/128 {{ SUBNET }} {{ RELAYNETS.split(",") | join(' ') }}
# Empty alias list to override the configuration variable and disable NIS
alias_maps =

@ -7,7 +7,7 @@ import multiprocessing
import logging as log
import sys
from podop import run_server
from podop import run_server
from pwd import getpwnam
from socrate import system, conf

@ -0,0 +1,3 @@
{% if RELAYNETS %}
local_networks = [{{ RELAYNETS }}];
{% endif %}

@ -0,0 +1,4 @@
apply {
# see https://github.com/Mailu/Mailu/issues/1705
RCVD_NO_TLS_LAST = 0;
}

@ -73,14 +73,14 @@ The ``MESSAGE_RATELIMIT`` is the limit of messages a single user can send. This
meant to fight outbound spam in case of compromised or malicious account on the
server.
The ``RELAYNETS`` are network addresses for which mail is relayed for free with
no authentication required. This should be used with great care. If you want other
Docker services' outbound mail to be relayed, you can set this to ``172.16.0.0/12``
to include **all** Docker networks. The default is to leave this empty.
The ``RELAYNETS`` (default: unset) is a comma delimited list of network addresses
for which mail is relayed for with no authentication required. This should be
used with great care as misconfigurations may turn your Mailu instance into an
open-relay!
The ``RELAYHOST`` is an optional address of a mail server relaying all outgoing
mail in following format: ``[HOST]:PORT``.
``RELAYUSER`` and ``RELAYPASSWORD`` can be used when authentication is needed.
The ``RELAYHOST`` is an optional address to use as a smarthost for all outgoing
mail in following format: ``[HOST]:PORT``. ``RELAYUSER`` and ``RELAYPASSWORD``
can be used when authentication is required.
By default postfix uses "opportunistic TLS" for outbound mail. This can be changed
by setting ``OUTBOUND_TLS_LEVEL`` to ``encrypt`` or ``secure``. This setting is

@ -47,19 +47,15 @@ Then on your own frontend, point to these local ports. In practice, you only nee
}
}
Because the admin interface is served as ``/admin`` and the Webmail as ``/webmail`` you may also want to use a single virtual host and serve other applications (still Nginx):
Because the admin interface is served as ``/admin``, the Webmail as ``/webmail``, the single sign on page as ``/sso``, webdav as ``/webdav`` and the static files endpoint as ``/static``, you may also want to use a single virtual host and serve other applications (still Nginx):
.. code-block:: nginx
server {
# [...] here goes your standard configuration
location /webmail {
proxy_pass https://localhost:8443/webmail;
}
location /admin {
proxy_pass https://localhost:8443/admin;
location ~ ^/(admin|sso|static|webdav|webmail)/ {
proxy_pass https://localhost:8443;
proxy_set_header Host $http_host;
}

@ -90,7 +90,6 @@ services:
env_file: {{ env }}
volumes:
- "{{ root }}/filter:/var/lib/rspamd"
- "{{ root }}/dkim:/dkim:ro"
- "{{ root }}/overrides/rspamd:/etc/rspamd/override.d:ro"
depends_on:
- front

@ -74,7 +74,6 @@ services:
env_file: {{ env }}
volumes:
- "{{ root }}/filter:/var/lib/rspamd"
- "{{ root }}/dkim:/dkim:ro"
- "{{ root }}/overrides/rspamd:/etc/rspamd/override.d:ro"
deploy:
replicas: 1

@ -0,0 +1 @@
Ensure that RCVD_NO_TLS_LAST doesn't add to the spam score (as TLS usage can't be determined)

@ -0,0 +1,10 @@
Improved the SSO page. Warning! The new endpoints /sso and /static are introduced.
These endpoints are now used for handling sign on requests and shared static files.
You may want to update your reverse proxy to proxy /sso and /static to Mailu (to the front service).
The example section of using a reverse proxy is updated with this information.
- New SSO page is used for logging in Admin or Webmail.
- Made SSO page available separately. SSO page can now be used without Admin accessible (ADMIN=false).
- Introduced stub /static which is used by all sites for accessing static files.
- Removed the /admin/ prefix to reduce complexity of routing with Mailu. Admin is accessible directly via /admin instead of /admin/ui
Note: Failed logon attempts are logged in the logs of admin. You can watch this with fail2ban.

@ -0,0 +1 @@
RELAYNETS should be a comma separated list of networks

@ -8,10 +8,8 @@ allow_admin_panel = Off
[labs]
allow_gravatar = Off
{% if ADMIN == "true" %}
custom_login_link='sso.php'
custom_logout_link='{{ WEB_ADMIN }}/ui/logout'
{% endif %}
custom_logout_link='/sso/logout'
[contacts]
enable = On

@ -37,11 +37,11 @@ $config['managesieve_usetls'] = false;
// Customization settings
if (filter_var(getenv('ADMIN'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) {
array_push($config['plugins'], 'mailu');
$config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : '';
$config['sso_logout_url'] = getenv('WEB_ADMIN').'/ui/logout';
$config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : '';
}
$config['product_name'] = 'Mailu Webmail';
array_push($config['plugins'], 'mailu');
$config['sso_logout_url'] = '/sso/logout';
// We access the IMAP and SMTP servers locally with internal names, SSL
// will obviously fail but this sounds better than allowing insecure login

Loading…
Cancel
Save