Merge remote-tracking branch 'upstream/master' into feat-psql-support
@ -7,7 +7,6 @@ pull_request_rules:
|
||||
actions:
|
||||
merge:
|
||||
method: merge
|
||||
strict: true
|
||||
dismiss_reviews:
|
||||
approved: true
|
||||
|
||||
@ -20,6 +19,5 @@ pull_request_rules:
|
||||
actions:
|
||||
merge:
|
||||
method: merge
|
||||
strict: true
|
||||
dismiss_reviews:
|
||||
approved: true
|
||||
|
@ -76,6 +76,7 @@ v1.6.0 - unreleased
|
||||
- Enhancement: Move Mailu Docker network to a fixed subnet ([#727](https://github.com/Mailu/Mailu/issues/727))
|
||||
- Enhancement: Added regex validation for alias username ([#764](https://github.com/Mailu/Mailu/issues/764))
|
||||
- Enhancement: Update documentation
|
||||
- Enhancement: Include favicon package ([#801](https://github.com/Mailu/Mailu/issues/801), ([#802](https://github.com/Mailu/Mailu/issues/802))
|
||||
- Upstream: Update Roundcube
|
||||
- Upstream: Update Rainloop
|
||||
- Bug: Rainloop fails with "domain not allowed" ([#93](https://github.com/Mailu/Mailu/issues/93))
|
||||
@ -107,6 +108,7 @@ v1.6.0 - unreleased
|
||||
- Bug: Hostname resolving in start.py should retry on failure [docker swarm] ([#555](https://github.com/Mailu/Mailu/issues/555))
|
||||
- Bug: Error when trying to log in with an account without domain ([#585](https://github.com/Mailu/Mailu/issues/585))
|
||||
- Bug: Fix rainloop permissions ([#637](https://github.com/Mailu/Mailu/issues/637))
|
||||
- Bug: Fix broken webmail and logo url in admin ([#792](https://github.com/Mailu/Mailu/issues/792))
|
||||
|
||||
v1.5.1 - 2017-11-21
|
||||
-------------------
|
||||
|
16
PULL_REQUEST_TEMPLATE.md
Normal file
@ -0,0 +1,16 @@
|
||||
## What type of PR?
|
||||
|
||||
(Feature, enhancement, bug-fix, documentation)
|
||||
|
||||
## What does this PR do?
|
||||
|
||||
### Related issue(s)
|
||||
- Mention an issue like: #001
|
||||
- Auto close an issue like: closes #001
|
||||
|
||||
## Prerequistes
|
||||
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.
|
||||
|
||||
- [ ] In case of feature or enhancement: documentation updated accordingly
|
||||
- [ ] Unless it's docs or a minor change: place entry in the [changelog](CHANGELOG.md), under the latest un-released version.
|
@ -33,5 +33,5 @@ if exists "X-Virus" {
|
||||
}
|
||||
|
||||
{% if user.reply_active %}
|
||||
vacation :days 1 :subject "{{ user.reply_subject }}" "{{ user.reply_body }}";
|
||||
vacation :days 1 {% if user.displayed_name != "" %}:from "{{ user.displayed_name }} <{{ user.email }}>"{% endif %} :subject "{{ user.reply_subject }}" "{{ user.reply_body }}";
|
||||
{% endif %}
|
||||
|
@ -6,7 +6,7 @@ import flask
|
||||
import socket
|
||||
import os
|
||||
|
||||
@internal.route("/dovecot/passdb/<user_email>")
|
||||
@internal.route("/dovecot/passdb/<path:user_email>")
|
||||
def dovecot_passdb_dict(user_email):
|
||||
user = models.User.query.get(user_email) or flask.abort(404)
|
||||
allow_nets = []
|
||||
@ -20,7 +20,7 @@ def dovecot_passdb_dict(user_email):
|
||||
})
|
||||
|
||||
|
||||
@internal.route("/dovecot/userdb/<user_email>")
|
||||
@internal.route("/dovecot/userdb/<path:user_email>")
|
||||
def dovecot_userdb_dict(user_email):
|
||||
user = models.User.query.get(user_email) or flask.abort(404)
|
||||
return flask.jsonify({
|
||||
@ -28,7 +28,7 @@ def dovecot_userdb_dict(user_email):
|
||||
})
|
||||
|
||||
|
||||
@internal.route("/dovecot/quota/<ns>/<user_email>", methods=["POST"])
|
||||
@internal.route("/dovecot/quota/<ns>/<path:user_email>", methods=["POST"])
|
||||
def dovecot_quota(ns, user_email):
|
||||
user = models.User.query.get(user_email) or flask.abort(404)
|
||||
if ns == "storage":
|
||||
@ -37,12 +37,12 @@ def dovecot_quota(ns, user_email):
|
||||
return flask.jsonify(None)
|
||||
|
||||
|
||||
@internal.route("/dovecot/sieve/name/<script>/<user_email>")
|
||||
@internal.route("/dovecot/sieve/name/<script>/<path:user_email>")
|
||||
def dovecot_sieve_name(script, user_email):
|
||||
return flask.jsonify(script)
|
||||
|
||||
|
||||
@internal.route("/dovecot/sieve/data/default/<user_email>")
|
||||
@internal.route("/dovecot/sieve/data/default/<path:user_email>")
|
||||
def dovecot_sieve_data(user_email):
|
||||
user = models.User.query.get(user_email) or flask.abort(404)
|
||||
return flask.jsonify(flask.render_template("default.sieve", user=user))
|
||||
|
@ -12,13 +12,13 @@ def postfix_mailbox_domain(domain_name):
|
||||
return flask.jsonify(domain.name)
|
||||
|
||||
|
||||
@internal.route("/postfix/mailbox/<email>")
|
||||
@internal.route("/postfix/mailbox/<path:email>")
|
||||
def postfix_mailbox_map(email):
|
||||
user = models.User.query.get(email) or flask.abort(404)
|
||||
return flask.jsonify(user.email)
|
||||
|
||||
|
||||
@internal.route("/postfix/alias/<alias>")
|
||||
@internal.route("/postfix/alias/<path:alias>")
|
||||
def postfix_alias_map(alias):
|
||||
localpart, domain_name = models.Email.resolve_domain(alias)
|
||||
if localpart is None:
|
||||
@ -27,7 +27,7 @@ def postfix_alias_map(alias):
|
||||
return flask.jsonify(",".join(destination)) if destination else flask.abort(404)
|
||||
|
||||
|
||||
@internal.route("/postfix/transport/<email>")
|
||||
@internal.route("/postfix/transport/<path:email>")
|
||||
def postfix_transport(email):
|
||||
if email == '*':
|
||||
return flask.abort(404)
|
||||
@ -36,7 +36,7 @@ def postfix_transport(email):
|
||||
return flask.jsonify("smtp:[{}]".format(relay.smtp))
|
||||
|
||||
|
||||
@internal.route("/postfix/sender/login/<sender>")
|
||||
@internal.route("/postfix/sender/login/<path:sender>")
|
||||
def postfix_sender_login(sender):
|
||||
localpart, domain_name = models.Email.resolve_domain(sender)
|
||||
if localpart is None:
|
||||
@ -45,7 +45,7 @@ def postfix_sender_login(sender):
|
||||
return flask.jsonify(",".join(destination)) if destination else flask.abort(404)
|
||||
|
||||
|
||||
@internal.route("/postfix/sender/access/<sender>")
|
||||
@internal.route("/postfix/sender/access/<path:sender>")
|
||||
def postfix_sender_access(sender):
|
||||
""" Simply reject any sender that pretends to be from a local domain
|
||||
"""
|
||||
|
@ -72,7 +72,7 @@ class CommaSeparatedList(db.TypeDecorator):
|
||||
return ",".join(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
return filter(bool, value.split(",")) if value else []
|
||||
return list(filter(bool, value.split(","))) if value else []
|
||||
|
||||
|
||||
class JSONEncoded(db.TypeDecorator):
|
||||
|
@ -6,7 +6,7 @@ import flask_login
|
||||
import flask_wtf
|
||||
import re
|
||||
|
||||
LOCALPART_REGEX = "^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+$"
|
||||
LOCALPART_REGEX = "^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*$"
|
||||
|
||||
class DestinationField(fields.SelectMultipleField):
|
||||
""" Allow for multiple emails selection from current user choices and
|
||||
@ -32,6 +32,14 @@ class DestinationField(fields.SelectMultipleField):
|
||||
if not self.validator.match(item):
|
||||
raise validators.ValidationError(_('Invalid email address.'))
|
||||
|
||||
class MultipleEmailAddressesVerify(object):
|
||||
def __init__(self,message=_('Invalid email address.')):
|
||||
self.message = message
|
||||
|
||||
def __call__(self, form, field):
|
||||
pattern = re.compile(r'^([_a-z0-9\-]+)(\.[_a-z0-9\-]+)*@([a-z0-9\-]{2,}\.)*([a-z]{2,4})(,([_a-z0-9\-]+)(\.[_a-z0-9\-]+)*@([a-z0-9\-]{2,}\.)*([a-z]{2,4}))*$')
|
||||
if not pattern.match(field.data.replace(" ", "")):
|
||||
raise validators.ValidationError(self.message)
|
||||
|
||||
class ConfirmationForm(flask_wtf.FlaskForm):
|
||||
submit = fields.SubmitField(_('Confirm'))
|
||||
@ -81,6 +89,7 @@ class UserForm(flask_wtf.FlaskForm):
|
||||
quota_bytes = fields_.IntegerSliderField(_('Quota'), default=1000000000)
|
||||
enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True)
|
||||
enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True)
|
||||
displayed_name = fields.StringField(_('Displayed name'))
|
||||
comment = fields.StringField(_('Comment'))
|
||||
enabled = fields.BooleanField(_('Enabled'), default=True)
|
||||
submit = fields.SubmitField(_('Save'))
|
||||
@ -101,9 +110,7 @@ class UserSettingsForm(flask_wtf.FlaskForm):
|
||||
spam_threshold = fields_.IntegerSliderField(_('Spam filter tolerance'))
|
||||
forward_enabled = fields.BooleanField(_('Enable forwarding'))
|
||||
forward_keep = fields.BooleanField(_('Keep a copy of the emails'))
|
||||
forward_destination = fields.StringField(
|
||||
_('Destination'), [validators.Optional(), validators.Email()]
|
||||
)
|
||||
forward_destination = fields.StringField(_('Destination'), [validators.Optional(), MultipleEmailAddressesVerify()])
|
||||
submit = fields.SubmitField(_('Save settings'))
|
||||
|
||||
|
||||
|
@ -13,6 +13,13 @@
|
||||
|
||||
{% block head %}
|
||||
{{super()}}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#00aba9">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
{% block scripts %}
|
||||
{{super()}}
|
||||
<script src="{{ url_for('.static', filename='select2/js/select2.min.js') }}"></script>
|
||||
@ -28,7 +35,7 @@ class="hold-transition skin-blue sidebar-mini"
|
||||
<div class="wrapper">
|
||||
{% block navbar %}
|
||||
<header class="main-header">
|
||||
<a href="/admin/" class="logo">
|
||||
<a href="{{ config["WEB_ADMIN"] }}" class="logo">
|
||||
<span class="logo-lg">{{ config["SITENAME"] }}</span>
|
||||
</a>
|
||||
</header>
|
||||
|
@ -69,7 +69,7 @@
|
||||
<li class="header">{% trans %}Go to{% endtrans %}</li>
|
||||
{% if config["WEBMAIL"] != "none" %}
|
||||
<li>
|
||||
<a href="{{ config["WEB_WEBMAIL"] }}/">
|
||||
<a href="{{ config["WEB_WEBMAIL"] }}">
|
||||
<i class="fa fa-envelope-o"></i> <span>{% trans %}Webmail{% endtrans %}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
@ -15,6 +15,7 @@
|
||||
{% call macros.box(_("General")) %}
|
||||
{{ macros.form_field(form.localpart, append='<span class="input-group-addon">@'+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 %}
|
||||
|
@ -11,6 +11,10 @@
|
||||
{% block content %}
|
||||
<form class="form" method="post" role="form">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
{% call macros.box(title=_("Displayed name")) %}
|
||||
{{ macros.form_field(form.displayed_name) }}
|
||||
{% endcall %}
|
||||
|
||||
{% call macros.box(title=_("Antispam")) %}
|
||||
{{ macros.form_field(form.spam_enabled) }}
|
||||
|
@ -33,7 +33,7 @@ def admin_create():
|
||||
return flask.render_template('admin/create.html', form=form)
|
||||
|
||||
|
||||
@ui.route('/admin/delete/<admin>', methods=['GET', 'POST'])
|
||||
@ui.route('/admin/delete/<path:admin>', methods=['GET', 'POST'])
|
||||
@access.global_admin
|
||||
@access.confirmation_required("delete admin {admin}")
|
||||
def admin_delete(admin):
|
||||
|
@ -36,7 +36,7 @@ def alias_create(domain_name):
|
||||
domain=domain, form=form)
|
||||
|
||||
|
||||
@ui.route('/alias/edit/<alias>', methods=['GET', 'POST'])
|
||||
@ui.route('/alias/edit/<path:alias>', methods=['GET', 'POST'])
|
||||
@access.domain_admin(models.Alias, 'alias')
|
||||
def alias_edit(alias):
|
||||
alias = models.Alias.query.get(alias) or flask.abort(404)
|
||||
@ -53,7 +53,7 @@ def alias_edit(alias):
|
||||
form=form, alias=alias, domain=alias.domain)
|
||||
|
||||
|
||||
@ui.route('/alias/delete/<alias>', methods=['GET', 'POST'])
|
||||
@ui.route('/alias/delete/<path:alias>', methods=['GET', 'POST'])
|
||||
@access.domain_admin(models.Alias, 'alias')
|
||||
@access.confirmation_required("delete {alias}")
|
||||
def alias_delete(alias):
|
||||
|
@ -6,7 +6,7 @@ import flask_login
|
||||
|
||||
|
||||
@ui.route('/fetch/list', methods=['GET', 'POST'], defaults={'user_email': None})
|
||||
@ui.route('/fetch/list/<user_email>', methods=['GET'])
|
||||
@ui.route('/fetch/list/<path:user_email>', methods=['GET'])
|
||||
@access.owner(models.User, 'user_email')
|
||||
def fetch_list(user_email):
|
||||
user_email = user_email or flask_login.current_user.email
|
||||
@ -15,7 +15,7 @@ def fetch_list(user_email):
|
||||
|
||||
|
||||
@ui.route('/fetch/create', methods=['GET', 'POST'], defaults={'user_email': None})
|
||||
@ui.route('/fetch/create/<user_email>', methods=['GET', 'POST'])
|
||||
@ui.route('/fetch/create/<path:user_email>', methods=['GET', 'POST'])
|
||||
@access.owner(models.User, 'user_email')
|
||||
def fetch_create(user_email):
|
||||
user_email = user_email or flask_login.current_user.email
|
||||
|
@ -38,7 +38,7 @@ def manager_create(domain_name):
|
||||
domain=domain, form=form)
|
||||
|
||||
|
||||
@ui.route('/manager/delete/<domain_name>/<user_email>', methods=['GET', 'POST'])
|
||||
@ui.route('/manager/delete/<domain_name>/<path:user_email>', methods=['GET', 'POST'])
|
||||
@access.confirmation_required("remove manager {user_email}")
|
||||
@access.domain_admin(models.Domain, 'domain_name')
|
||||
def manager_delete(domain_name, user_email):
|
||||
|
@ -9,7 +9,7 @@ import wtforms_components
|
||||
|
||||
|
||||
@ui.route('/token/list', methods=['GET', 'POST'], defaults={'user_email': None})
|
||||
@ui.route('/token/list/<user_email>', methods=['GET'])
|
||||
@ui.route('/token/list/<path:user_email>', methods=['GET'])
|
||||
@access.owner(models.User, 'user_email')
|
||||
def token_list(user_email):
|
||||
user_email = user_email or flask_login.current_user.email
|
||||
@ -18,7 +18,7 @@ def token_list(user_email):
|
||||
|
||||
|
||||
@ui.route('/token/create', methods=['GET', 'POST'], defaults={'user_email': None})
|
||||
@ui.route('/token/create/<user_email>', methods=['GET', 'POST'])
|
||||
@ui.route('/token/create/<path:user_email>', methods=['GET', 'POST'])
|
||||
@access.owner(models.User, 'user_email')
|
||||
def token_create(user_email):
|
||||
user_email = user_email or flask_login.current_user.email
|
||||
|
@ -7,7 +7,6 @@ import flask_login
|
||||
import wtforms
|
||||
import wtforms_components
|
||||
|
||||
|
||||
@ui.route('/user/list/<domain_name>', methods=['GET'])
|
||||
@access.domain_admin(models.Domain, 'domain_name')
|
||||
def user_list(domain_name):
|
||||
@ -44,7 +43,7 @@ def user_create(domain_name):
|
||||
domain=domain, form=form)
|
||||
|
||||
|
||||
@ui.route('/user/edit/<user_email>', methods=['GET', 'POST'])
|
||||
@ui.route('/user/edit/<path:user_email>', methods=['GET', 'POST'])
|
||||
@access.domain_admin(models.User, 'user_email')
|
||||
def user_edit(user_email):
|
||||
user = models.User.query.get(user_email) or flask.abort(404)
|
||||
@ -72,7 +71,7 @@ def user_edit(user_email):
|
||||
domain=user.domain, max_quota_bytes=max_quota_bytes)
|
||||
|
||||
|
||||
@ui.route('/user/delete/<user_email>', methods=['GET', 'POST'])
|
||||
@ui.route('/user/delete/<path:user_email>', methods=['GET', 'POST'])
|
||||
@access.domain_admin(models.User, 'user_email')
|
||||
@access.confirmation_required("delete {user_email}")
|
||||
def user_delete(user_email):
|
||||
@ -86,15 +85,22 @@ def user_delete(user_email):
|
||||
|
||||
|
||||
@ui.route('/user/settings', methods=['GET', 'POST'], defaults={'user_email': None})
|
||||
@ui.route('/user/usersettings/<user_email>', methods=['GET', 'POST'])
|
||||
@ui.route('/user/usersettings/<path:user_email>', methods=['GET', 'POST'])
|
||||
@access.owner(models.User, 'user_email')
|
||||
def user_settings(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.UserSettingsForm(obj=user)
|
||||
if isinstance(form.forward_destination.data,str):
|
||||
data = form.forward_destination.data.replace(" ","").split(",")
|
||||
else:
|
||||
data = form.forward_destination.data
|
||||
form.forward_destination.data = ", ".join(data)
|
||||
if form.validate_on_submit():
|
||||
form.forward_destination.data = form.forward_destination.data.replace(" ","").split(",")
|
||||
form.populate_obj(user)
|
||||
models.db.session.commit()
|
||||
form.forward_destination.data = ", ".join(form.forward_destination.data)
|
||||
flask.flash('Settings updated for %s' % user)
|
||||
if user_email:
|
||||
return flask.redirect(
|
||||
@ -103,7 +109,7 @@ def user_settings(user_email):
|
||||
|
||||
|
||||
@ui.route('/user/password', methods=['GET', 'POST'], defaults={'user_email': None})
|
||||
@ui.route('/user/password/<user_email>', methods=['GET', 'POST'])
|
||||
@ui.route('/user/password/<path:user_email>', methods=['GET', 'POST'])
|
||||
@access.owner(models.User, 'user_email')
|
||||
def user_password(user_email):
|
||||
user_email_or_current = user_email or flask_login.current_user.email
|
||||
@ -123,7 +129,7 @@ def user_password(user_email):
|
||||
|
||||
|
||||
@ui.route('/user/forward', methods=['GET', 'POST'], defaults={'user_email': None})
|
||||
@ui.route('/user/forward/<user_email>', methods=['GET', 'POST'])
|
||||
@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
|
||||
@ -140,7 +146,7 @@ def user_forward(user_email):
|
||||
|
||||
|
||||
@ui.route('/user/reply', methods=['GET', 'POST'], defaults={'user_email': None})
|
||||
@ui.route('/user/reply/<user_email>', methods=['GET', 'POST'])
|
||||
@ui.route('/user/reply/<path:user_email>', methods=['GET', 'POST'])
|
||||
@access.owner(models.User, 'user_email')
|
||||
def user_reply(user_email):
|
||||
user_email_or_current = user_email or flask_login.current_user.email
|
||||
|
@ -36,7 +36,7 @@ pyOpenSSL==18.0.0
|
||||
python-dateutil==2.7.5
|
||||
python-editor==1.0.3
|
||||
pytz==2018.7
|
||||
PyYAML==3.13
|
||||
PyYAML==4.2b4
|
||||
redis==3.0.1
|
||||
six==1.11.0
|
||||
SQLAlchemy==1.2.13
|
||||
|
@ -10,6 +10,7 @@ RUN apk add --no-cache certbot nginx nginx-mod-mail openssl curl \
|
||||
&& pip3 install idna requests watchdog
|
||||
|
||||
COPY conf /conf
|
||||
COPY static /static
|
||||
COPY *.py /
|
||||
|
||||
EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp 10025/tcp 10143/tcp
|
||||
|
@ -38,6 +38,8 @@ http {
|
||||
{% if KUBERNETES_INGRESS != 'true' %}
|
||||
# Main HTTP server
|
||||
server {
|
||||
# Favicon stuff
|
||||
root /static;
|
||||
# Variables for proxifying
|
||||
set $admin {{ HOST_ADMIN }};
|
||||
set $antispam {{ HOST_ANTISPAM }};
|
||||
@ -90,9 +92,9 @@ http {
|
||||
{% if WEB_WEBMAIL != '/' %}
|
||||
location / {
|
||||
{% if WEBROOT_REDIRECT %}
|
||||
return 301 {{ WEBROOT_REDIRECT }};
|
||||
try_files $uri {{ WEBROOT_REDIRECT }};
|
||||
{% else %}
|
||||
return 404;
|
||||
try_files $uri =404;
|
||||
{% endif %}
|
||||
}
|
||||
{% endif %}
|
||||
|
BIN
core/nginx/static/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
core/nginx/static/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
core/nginx/static/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
9
core/nginx/static/browserconfig.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#00aba9</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
BIN
core/nginx/static/favicon-16x16.png
Normal file
After Width: | Height: | Size: 978 B |
BIN
core/nginx/static/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
core/nginx/static/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
core/nginx/static/mstile-150x150.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
25
core/nginx/static/safari-pinned-tab.svg
Normal file
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="536.000000pt" height="536.000000pt" viewBox="0 0 536.000000 536.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,536.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M2508 5346 c-1 -2 -38 -6 -80 -10 -120 -10 -319 -44 -418 -71 -196
|
||||
-53 -336 -107 -526 -200 -128 -63 -346 -197 -419 -257 -11 -9 -54 -44 -95 -78
|
||||
-429 -353 -738 -840 -880 -1385 -16 -61 -24 -98 -45 -205 -3 -14 -8 -47 -11
|
||||
-75 -4 -27 -8 -53 -10 -56 -7 -13 -18 -230 -18 -354 2 -261 42 -536 109 -745
|
||||
8 -25 21 -65 29 -90 30 -96 100 -260 160 -376 255 -489 653 -889 1136 -1141
|
||||
103 -53 245 -118 285 -129 6 -1 35 -12 65 -24 48 -18 164 -54 210 -65 8 -2 45
|
||||
-10 81 -19 208 -48 333 -61 599 -61 176 0 328 8 384 20 12 2 35 6 51 9 293 45
|
||||
623 162 895 319 466 267 862 694 1082 1167 27 58 53 114 58 125 61 127 159
|
||||
484 176 640 3 28 7 55 9 62 8 25 19 242 18 353 -6 552 -175 1074 -495 1525
|
||||
-71 101 -82 114 -189 234 -255 286 -568 513 -924 669 -178 78 -422 152 -590
|
||||
178 -16 3 -41 7 -55 10 -14 2 -47 7 -75 10 -27 3 -61 8 -75 11 -28 5 -436 13
|
||||
-442 9z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
19
core/nginx/static/site.webmanifest
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
@ -71,6 +71,14 @@ Web settings
|
||||
|
||||
The ``WEB_ADMIN`` contains the path to the main admin interface, while
|
||||
``WEB_WEBMAIL`` contains the path to the Web email client.
|
||||
The ``WEBROOT_REDIRECT`` redirects all non-found queries to the set path.
|
||||
An empty ``WEBROOT_REDIRECT`` value disables redirecting and enables classic
|
||||
behavior of a 404 result when not found.
|
||||
All three options need a leading slash (``/``) to work.
|
||||
|
||||
.. note:: ``WEBROOT_REDIRECT`` has to point to a valid path on the webserver.
|
||||
This means it cannot point to any services which are not enabled.
|
||||
For example, don't point it to ``/webmail`` when ``WEBMAIL=none``
|
||||
|
||||
Both ``SITENAME`` and ``WEBSITE`` are customization options for the panel menu
|
||||
in the admin interface, while ``SITENAME`` is a customization option for
|
||||
|
@ -18,7 +18,7 @@ Functionality
|
||||
|
||||
- The server is reset every day at 3am, UTC.
|
||||
- You can send mail from any client to the server.
|
||||
However, the stmp server is made incapable of relaying the e-mail to the destination server.
|
||||
However, the SMTP server is made incapable of relaying the e-mail to the destination server.
|
||||
As such, the mail will never arrive. This is to prevent abuse of the server.
|
||||
- The server is capable of receiving mail for any configured domains.
|
||||
- The server exposes IMAP, POP3 and SMTP as usual for connection with mail clients such as Thunderbird.
|
||||
|
149
docs/faq.rst
@ -89,6 +89,51 @@ our ongoing `project management`_ discussion issue.
|
||||
Deployment related
|
||||
------------------
|
||||
|
||||
What is the difference between DOMAIN and HOSTNAMES?
|
||||
````````````````````````````````````````````````````
|
||||
|
||||
Similar questions:
|
||||
|
||||
- Changing domain doesn't work
|
||||
- Do I need a certificate for ``DOMAIN``?
|
||||
|
||||
``DOMAIN`` is the main mail domain. Aka, server identification for outgoing mail. DMARC reports point to ``POSTMASTER`` @ ``DOMAIN``.
|
||||
These are really the only things it is used for. You don't need a cert for ``DOMAIN``, as it is a mail domain only and not used as host in any sense.
|
||||
However, it is usual that ``DOMAIN`` gets setup as one of the many mail domains. None of the mail domains ever need a certificate.
|
||||
TLS certificates work on host connection level only.
|
||||
|
||||
``HOSTNAMES`` however, can be used to connect to the server. All host names supplied in this variable will need a certificate. When ``TLS_FLAVOR=letsencrypt`` is set,
|
||||
a certificate is requested automatically for all those domains.
|
||||
|
||||
So when you have something like this:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
DOMAIN=example.com
|
||||
POSTMASTER=me
|
||||
HOSTNAMES=mail.example.com,mail.foo.com,bar.com
|
||||
TLS_FLAVOR=letsencrypt
|
||||
|
||||
- You'll end up with a DMARC address to ``me@example.com``.
|
||||
- Server identifies itself as the SMTP server of ``@example.com`` when sending mail. Make sure your reverse DNS hostname is part of that domain!
|
||||
- Your server will have certificates for the 3 hostnames. You will need to create ``A`` and ``AAAA`` records for those names,
|
||||
pointing to the IP addresses of your server.
|
||||
- The admin interface generates ``MX`` and ``SPF`` examples which point to the first entry of ``HOSTNAMES`` but these are only examples.
|
||||
You can modify them to use any other ``HOSTNAMES`` entry.
|
||||
|
||||
You're mail service will be reachable for IMAP, POP3, SMTP and Webmail at the addresses:
|
||||
|
||||
- mail.example.com
|
||||
- mail.foo.com
|
||||
- bar.com
|
||||
|
||||
.. note::
|
||||
|
||||
In this case ``example.com`` is not reachable as a host and will not have a certificate.
|
||||
It can be used as a mail domain if MX is setup to point to one of the ``HOSTNAMES``. However, it is possible to include ``example.com`` in ``HOSTNAMES``.
|
||||
|
||||
*Issue reference:* `742`_, `747`_.
|
||||
|
||||
How does Mailu scale up?
|
||||
````````````````````````
|
||||
|
||||
@ -123,6 +168,16 @@ For **service** HA, please see: `How does Mailu scale up?`_
|
||||
|
||||
.. _`spam magnet`: https://blog.zensoftware.co.uk/2012/07/02/why-we-tend-to-recommend-not-having-a-secondary-mx-these-days/
|
||||
|
||||
Does Mailu run on Rancher?
|
||||
``````````````````````````
|
||||
|
||||
There is a rancher catalog for Mailu in the `Mailu/Rancher`_ repository. The user group for Rancher is small,
|
||||
so we cannot promise any support on this when you're heading into trouble. See the repository README for more details.
|
||||
|
||||
*Issue reference:* `125`_.
|
||||
|
||||
.. _`Mailu/Rancher`: https://github.com/Mailu/Rancher
|
||||
|
||||
|
||||
Can I run Mailu without host iptables?
|
||||
``````````````````````````````````````
|
||||
@ -138,24 +193,67 @@ For that reason we do **not** support deployment on Docker hosts without iptable
|
||||
How can I override settings?
|
||||
````````````````````````````
|
||||
|
||||
Postfix, dovecot and Rspamd support overriding configuration files. Override files belong in
|
||||
Postfix, Dovecot, Nginx and Rspamd support overriding configuration files. Override files belong in
|
||||
``$ROOT/overrides``. Please refer to the official documentation of those programs for the
|
||||
correct syntax. The following file names will be taken as override configuration:
|
||||
|
||||
- `Postfix`_ - ``postfix.cf``;
|
||||
- `Dovecot`_ - ``dovecot.conf``;
|
||||
- `Nginx`_ - All ``*.conf`` files in the ``nginx`` sub-directory.
|
||||
- `Rspamd`_ - All files in the ``rspamd`` sub-directory.
|
||||
|
||||
*Issue reference:* `206`_.
|
||||
|
||||
I want to integrate Nextcloud with Mailu
|
||||
````````````````````````````````````````
|
||||
|
||||
First of all you have to install dependencies required to authenticate users via imap in Nextcloud
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
apt-get update \
|
||||
&& apt-get install -y libc-client-dev libkrb5-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& docker-php-ext-configure imap --with-kerberos --with-imap-ssl \
|
||||
&& docker-php-ext-install imap
|
||||
|
||||
Next, you have to enable External user support from Nextcloud Apps interface
|
||||
|
||||
In the end you need to configure additional user backends in Nextcloud’s configuration config/config.php using the following syntax:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
<?php
|
||||
|
||||
'user_backends' => array(
|
||||
array(
|
||||
'class' => 'OC_User_IMAP',
|
||||
'arguments' => array(
|
||||
'{imap.example.com:993/imap/ssl}', 'example.com'
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
If a domain name (e.g. example.com) is specified, then this makes sure that only users from this domain will be allowed to login.
|
||||
After successfull login the domain part will be striped and the rest used as username in NextCloud. e.g. 'username@example.com' will be 'username' in NextCloud.
|
||||
|
||||
*Issue reference:* `575`_.
|
||||
|
||||
.. _`Postfix`: http://www.postfix.org/postconf.5.html
|
||||
.. _`Dovecot`: https://wiki.dovecot.org/ConfigFile
|
||||
.. _`Rspamd`: https://www.rspamd.com/doc/configuration/index.html
|
||||
.. _`NGINX`: https://nginx.org/en/docs/
|
||||
.. _`Rspamd`: https://www.rspamd.com/doc/configuration/index.html
|
||||
|
||||
.. _`Docker swarm howto`: https://github.com/Mailu/Mailu/tree/master/docs/swarm/master
|
||||
.. _`125`: https://github.com/Mailu/Mailu/issues/125
|
||||
.. _`165`: https://github.com/Mailu/Mailu/issues/165
|
||||
.. _`177`: https://github.com/Mailu/Mailu/issues/177
|
||||
.. _`332`: https://github.com/Mailu/Mailu/issues/332
|
||||
.. _`742`: https://github.com/Mailu/Mailu/issues/742
|
||||
.. _`747`: https://github.com/Mailu/Mailu/issues/747
|
||||
.. _`520`: https://github.com/Mailu/Mailu/issues/520
|
||||
.. _`591`: https://github.com/Mailu/Mailu/issues/591
|
||||
.. _`575`: https://github.com/Mailu/Mailu/issues/575
|
||||
|
||||
Technical issues
|
||||
----------------
|
||||
@ -241,8 +339,18 @@ See also :ref:`external_certs`.
|
||||
|
||||
*Issue reference:* `426`_, `615`_.
|
||||
|
||||
How do I activate DKIM and DMARC?
|
||||
`````````````````````````````````
|
||||
Go into the Domain Panel and choose the Domain you want to enable DKIM for.
|
||||
Click the first icon on the left side (domain details).
|
||||
Now click on the top right on the *"Regenerate Keys"* Button.
|
||||
This will generate the DKIM and DMARC entries for you.
|
||||
|
||||
*Issue reference:* `102`_.
|
||||
|
||||
Do you support Fail2Ban?
|
||||
````````````````````````
|
||||
|
||||
Fail2Ban is not included in Mailu. Fail2Ban needs to modify the host's IP tables in order to
|
||||
ban the addresses. We consider such a program should be run on the host system and not
|
||||
inside a container. The ``front`` container does use authentication rate limiting to slow
|
||||
@ -265,12 +373,49 @@ spam filter weight settings.
|
||||
|
||||
*Issue reference:* `503`_.
|
||||
|
||||
rspamd: DNS query blocked on multi.uribl.com
|
||||
````````````````````````````````````````````
|
||||
|
||||
This usually relates to the DNS server you are using. Most of the public servers block this query or there is a rate limit.
|
||||
In order to solve this, you most probably are better off using a root DNS resolver, such as `unbound`_. This can be done in multiple ways:
|
||||
|
||||
- Use the *Mailu/unbound* container. This is an optional include when generating the ``docker-compose.yml`` file with the setup utility.
|
||||
- Setup unbound on the host and make sure the host's ``/etc/resolve.conf`` points to local host.
|
||||
Docker will then forward all external DNS requests to the local server.
|
||||
- Set up an external DNS server with root resolving capabilities.
|
||||
|
||||
In any case, using a dedicated DNS server will improve the performance of your mail server.
|
||||
|
||||
*Issue reference:* `206`_, `554`_, `681`_.
|
||||
|
||||
Is there a way to support more (older) ciphers?
|
||||
```````````````````````````````````````````````
|
||||
|
||||
See `How can I override settings?`_ .
|
||||
You will need to add the protocols you wish to support in an override for the ``front`` container (Nginx).
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
ssl_ciphers <list of ciphers>;
|
||||
|
||||
We **strongly** advice against downgrading the TLS version and ciphers!
|
||||
|
||||
*Issue reference:* `363`_, `698`_.
|
||||
|
||||
.. _`troubleshooting tag`: https://github.com/Mailu/Mailu/issues?utf8=%E2%9C%93&q=label%3Afaq%2Ftroubleshooting
|
||||
.. _`85`: https://github.com/Mailu/Mailu/issues/85
|
||||
.. _`102`: https://github.com/Mailu/Mailu/issues/102
|
||||
.. _`116`: https://github.com/Mailu/Mailu/issues/116
|
||||
.. _`171`: https://github.com/Mailu/Mailu/issues/171
|
||||
.. _`206`: https://github.com/Mailu/Mailu/issues/206
|
||||
.. _`363`: https://github.com/Mailu/Mailu/issues/363
|
||||
.. _`426`: https://github.com/Mailu/Mailu/issues/426
|
||||
.. _`503`: https://github.com/Mailu/Mailu/issues/503
|
||||
.. _`554`: https://github.com/Mailu/Mailu/issues/554
|
||||
.. _`584`: https://github.com/Mailu/Mailu/issues/584
|
||||
.. _`592`: https://github.com/Mailu/Mailu/issues/592
|
||||
.. _`615`: https://github.com/Mailu/Mailu/issues/615
|
||||
.. _`681`: https://github.com/Mailu/Mailu/pull/681
|
||||
.. _`698`: https://github.com/Mailu/Mailu/issues/698
|
||||
.. _`unbound`: https://nlnetlabs.nl/projects/unbound/about/
|
||||
|
@ -5,17 +5,21 @@ version: '3.6'
|
||||
services:
|
||||
redis:
|
||||
image: redis:alpine
|
||||
networks:
|
||||
- default
|
||||
|
||||
setup_master:
|
||||
image: mailu/setup:master
|
||||
networks:
|
||||
- web
|
||||
- default
|
||||
env_file: .env
|
||||
environment:
|
||||
this_version: "master"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.port=80
|
||||
- traefik.docker.network=web
|
||||
- traefik.main.frontend.rule=Host:${ADDRESS};PathPrefix:/master/
|
||||
depends_on:
|
||||
- redis
|
||||
@ -24,12 +28,14 @@ services:
|
||||
image: mailu/setup:${RELEASE}
|
||||
networks:
|
||||
- web
|
||||
- default
|
||||
env_file: .env
|
||||
environment:
|
||||
this_version: ${RELEASE}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.port=80
|
||||
- traefik.docker.network=web
|
||||
- traefik.root.frontend.redirect.regex=.*
|
||||
- traefik.root.frontend.redirect.replacement=/${RELEASE}/
|
||||
- traefik.root.frontend.rule=Host:${ADDRESS};PathPrefix:/
|
||||
@ -40,3 +46,5 @@ services:
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
default:
|
||||
external: false
|
||||
|
@ -11,8 +11,8 @@ in a project directory. First create your project directory.</p>
|
||||
to read and check the configuration variables generated by the wizard.</p>
|
||||
|
||||
<pre><code>cd {{ root }}
|
||||
curl {{ url_for('.file', uid=uid, filepath='docker-compose.yml', _external=True) }} > docker-compose.yml
|
||||
curl {{ url_for('.file', uid=uid, filepath='mailu.env', _external=True) }} > mailu.env
|
||||
wget {{ url_for('.file', uid=uid, filepath='docker-compose.yml', _external=True) }}
|
||||
wget {{ url_for('.file', uid=uid, filepath='mailu.env', _external=True) }}
|
||||
</pre></code>
|
||||
{% endcall %}
|
||||
|
||||
|
@ -11,8 +11,8 @@ in a project directory. First create your project directory.</p>
|
||||
to read and check the configuration variables generated by the wizard.</p>
|
||||
|
||||
<pre><code>cd {{ root }}
|
||||
curl {{ url_for('.file', uid=uid, filepath='docker-compose.yml', _external=True) }} > docker-compose.yml
|
||||
curl {{ url_for('.file', uid=uid, filepath='mailu.env', _external=True) }} > mailu.env
|
||||
wget {{ url_for('.file', uid=uid, filepath='docker-compose.yml', _external=True) }}
|
||||
wget {{ url_for('.file', uid=uid, filepath='mailu.env', _external=True) }}
|
||||
</pre></code>
|
||||
{% endcall %}
|
||||
|
||||
|
@ -11,7 +11,9 @@ import ipaddress
|
||||
import hashlib
|
||||
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
version = os.getenv("this_version")
|
||||
static_url_path = "/" + version + "/static"
|
||||
app = flask.Flask(__name__, static_url_path=static_url_path)
|
||||
flask_bootstrap.Bootstrap(app)
|
||||
db = redis.StrictRedis(host='redis', port=6379, db=0)
|
||||
|
||||
@ -41,29 +43,37 @@ def build_app(path):
|
||||
def app_context():
|
||||
return dict(versions=os.getenv("VERSIONS","master").split(','))
|
||||
|
||||
version = os.getenv("this_version")
|
||||
|
||||
bp = flask.Blueprint(version, __name__)
|
||||
bp.jinja_loader = jinja2.ChoiceLoader([
|
||||
prefix_bp = flask.Blueprint(version, __name__)
|
||||
prefix_bp.jinja_loader = jinja2.ChoiceLoader([
|
||||
jinja2.FileSystemLoader(os.path.join(path, "templates")),
|
||||
jinja2.FileSystemLoader(os.path.join(path, "flavors"))
|
||||
])
|
||||
|
||||
@bp.context_processor
|
||||
root_bp = flask.Blueprint("root", __name__)
|
||||
root_bp.jinja_loader = jinja2.ChoiceLoader([
|
||||
jinja2.FileSystemLoader(os.path.join(path, "templates")),
|
||||
jinja2.FileSystemLoader(os.path.join(path, "flavors"))
|
||||
])
|
||||
|
||||
@prefix_bp.context_processor
|
||||
@root_bp.context_processor
|
||||
def bp_context(version=version):
|
||||
return dict(version=version)
|
||||
|
||||
@bp.route("/")
|
||||
@prefix_bp.route("/")
|
||||
@root_bp.route("/")
|
||||
def wizard():
|
||||
return flask.render_template('wizard.html')
|
||||
|
||||
@bp.route("/submit_flavor", methods=["POST"])
|
||||
@prefix_bp.route("/submit_flavor", methods=["POST"])
|
||||
@root_bp.route("/submit_flavor", methods=["POST"])
|
||||
def submit_flavor():
|
||||
data = flask.request.form.copy()
|
||||
steps = sorted(os.listdir(os.path.join(path, "templates", "steps", data["flavor"])))
|
||||
return flask.render_template('wizard.html', flavor=data["flavor"], steps=steps)
|
||||
|
||||
@bp.route("/submit", methods=["POST"])
|
||||
@prefix_bp.route("/submit", methods=["POST"])
|
||||
@root_bp.route("/submit", methods=["POST"])
|
||||
def submit():
|
||||
data = flask.request.form.copy()
|
||||
data['uid'] = str(uuid.uuid4())
|
||||
@ -71,14 +81,16 @@ def build_app(path):
|
||||
db.set(data['uid'], json.dumps(data))
|
||||
return flask.redirect(flask.url_for('.setup', uid=data['uid']))
|
||||
|
||||
@bp.route("/setup/<uid>", methods=["GET"])
|
||||
@prefix_bp.route("/setup/<uid>", methods=["GET"])
|
||||
@root_bp.route("/setup/<uid>", methods=["GET"])
|
||||
def setup(uid):
|
||||
data = json.loads(db.get(uid))
|
||||
flavor = data.get("flavor", "compose")
|
||||
rendered = render_flavor(flavor, "setup.html", data)
|
||||
return flask.render_template("setup.html", contents=rendered)
|
||||
|
||||
@bp.route("/file/<uid>/<filepath>", methods=["GET"])
|
||||
@prefix_bp.route("/file/<uid>/<filepath>", methods=["GET"])
|
||||
@root_bp.route("/file/<uid>/<filepath>", methods=["GET"])
|
||||
def file(uid, filepath):
|
||||
data = json.loads(db.get(uid))
|
||||
flavor = data.get("flavor", "compose")
|
||||
@ -87,7 +99,8 @@ def build_app(path):
|
||||
mimetype="application/text"
|
||||
)
|
||||
|
||||
app.register_blueprint(bp, url_prefix="/{}".format(version))
|
||||
app.register_blueprint(prefix_bp, url_prefix="/{}".format(version))
|
||||
app.register_blueprint(root_bp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -2,7 +2,8 @@ FROM php:7.2-apache
|
||||
#Shared layer between rainloop and roundcube
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 curl \
|
||||
&& rm -rf /var/lib/apt/lists
|
||||
&& rm -rf /var/lib/apt/lists \
|
||||
&& echo "ServerSignature Off" >> /etc/apache2/apache2.conf
|
||||
|
||||
ENV RAINLOOP_URL https://github.com/RainLoop/rainloop-webmail/releases/download/v1.12.1/rainloop-community-1.12.1.zip
|
||||
|
||||
|
@ -2,7 +2,8 @@ FROM php:7.2-apache
|
||||
#Shared layer between rainloop and roundcube
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 curl \
|
||||
&& rm -rf /var/lib/apt/lists
|
||||
&& rm -rf /var/lib/apt/lists \
|
||||
&& echo "ServerSignature Off" >> /etc/apache2/apache2.conf
|
||||
|
||||
ENV ROUNDCUBE_URL https://github.com/roundcube/roundcubemail/releases/download/1.3.8/roundcubemail-1.3.8-complete.tar.gz
|
||||
|
||||
|