Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79529a4aac |
77
.env.dist
Normal file
77
.env.dist
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Mailu main configuration file
|
||||||
|
#
|
||||||
|
# Most configuration variables can be modified through the Web interface,
|
||||||
|
# these few settings must however be configured before starting the mail
|
||||||
|
# server and require a restart upon change.
|
||||||
|
|
||||||
|
###################################
|
||||||
|
# Common configuration variables
|
||||||
|
###################################
|
||||||
|
|
||||||
|
# Set this to the path where Mailu data and configuration is stored
|
||||||
|
ROOT=/mailu
|
||||||
|
|
||||||
|
# Mailu version to run (stable, 1.0, 1.1, etc. or latest)
|
||||||
|
VERSION=stable
|
||||||
|
|
||||||
|
# Set to a randomly generated 16 bytes string
|
||||||
|
SECRET_KEY=ChangeMeChangeMe
|
||||||
|
|
||||||
|
# Address where listening ports should bind
|
||||||
|
BIND_ADDRESS=127.0.0.1
|
||||||
|
|
||||||
|
# Main mail domain
|
||||||
|
DOMAIN=mailu.io
|
||||||
|
|
||||||
|
# Exposed mail-server hostname
|
||||||
|
HOSTNAME=mail.mailu.io
|
||||||
|
|
||||||
|
# Postmaster local part (will append the main mail domain)
|
||||||
|
POSTMASTER=admin
|
||||||
|
|
||||||
|
# Docker-compose project name, this will prepended to containers names.
|
||||||
|
COMPOSE_PROJECT_NAME=mailu
|
||||||
|
|
||||||
|
###################################
|
||||||
|
# Optional features
|
||||||
|
###################################
|
||||||
|
|
||||||
|
# Choose which frontend Web server to run if any (value: nginx, none)
|
||||||
|
FRONTEND=none
|
||||||
|
|
||||||
|
# Choose which webmail to run if any (values: roundcube, rainloop, none)
|
||||||
|
WEBMAIL=none
|
||||||
|
|
||||||
|
# Expose the admin interface in publicly (values: yes, no)
|
||||||
|
EXPOSE_ADMIN=no
|
||||||
|
|
||||||
|
# Use Letsencrypt to generate a TLS certificate (uncomment to enable)
|
||||||
|
# ENABLE_CERTBOT=True
|
||||||
|
|
||||||
|
# Dav server implementation (value: radicale, none)
|
||||||
|
WEBDAV=none
|
||||||
|
|
||||||
|
###################################
|
||||||
|
# Mail settings
|
||||||
|
###################################
|
||||||
|
|
||||||
|
# Message size limit in bytes
|
||||||
|
# Default: accept messages up to 50MB
|
||||||
|
MESSAGE_SIZE_LIMIT=50000000
|
||||||
|
|
||||||
|
# Networks granted relay permissions, make sure that you include your Docker
|
||||||
|
# internal network (default to 172.17.0.0/16)
|
||||||
|
RELAYNETS=172.16.0.0/12
|
||||||
|
|
||||||
|
# Will relay all outgoing mails if configured
|
||||||
|
RELAYHOST=
|
||||||
|
|
||||||
|
# Fetchmail delay
|
||||||
|
FETCHMAIL_DELAY=600
|
||||||
|
|
||||||
|
###################################
|
||||||
|
# Developers
|
||||||
|
###################################
|
||||||
|
|
||||||
|
# Uncomment this to enable debugging globally
|
||||||
|
# DEBUG=True
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,15 +1,12 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.mo
|
*.mo
|
||||||
__pycache__
|
__pycache__
|
||||||
|
/admin/lib
|
||||||
|
/admin/bin
|
||||||
|
/admin/include
|
||||||
pip-selfcheck.json
|
pip-selfcheck.json
|
||||||
/core/admin/lib*
|
|
||||||
/core/admin/bin
|
|
||||||
/core/admin/include
|
|
||||||
/docs/lib*
|
|
||||||
/docs/bin
|
|
||||||
/docs/include
|
|
||||||
/docs/_build
|
|
||||||
/.env
|
/.env
|
||||||
|
/data
|
||||||
|
/docker-compose.mac.yml
|
||||||
/docker-compose.yml
|
/docker-compose.yml
|
||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
|
||||||
|
|||||||
25
.mergify.yml
25
.mergify.yml
@@ -1,25 +0,0 @@
|
|||||||
pull_request_rules:
|
|
||||||
- name: Successful travis and 2 approved reviews
|
|
||||||
conditions:
|
|
||||||
- status-success=continuous-integration/travis-ci/pr
|
|
||||||
- label!=["status"/wip","status/blocked"]
|
|
||||||
- "#approved-reviews-by>=2"
|
|
||||||
actions:
|
|
||||||
merge:
|
|
||||||
method: merge
|
|
||||||
strict: true
|
|
||||||
dismiss_reviews:
|
|
||||||
approved: true
|
|
||||||
|
|
||||||
- name: Trusted author, successful travis and 1 approved review
|
|
||||||
conditions:
|
|
||||||
- author~=(kaiyou|muhlemmer|mildred|HorayNarea|adi90x|hoellen|ofthesun9)
|
|
||||||
- status-success=continuous-integration/travis-ci/pr
|
|
||||||
- label!=["status"/wip","status/blocked","review/need2"]
|
|
||||||
- "#approved-reviews-by>=1"
|
|
||||||
actions:
|
|
||||||
merge:
|
|
||||||
method: merge
|
|
||||||
strict: true
|
|
||||||
dismiss_reviews:
|
|
||||||
approved: true
|
|
||||||
37
.travis.yml
37
.travis.yml
@@ -1,37 +0,0 @@
|
|||||||
sudo: required
|
|
||||||
services: docker
|
|
||||||
addons:
|
|
||||||
apt:
|
|
||||||
packages:
|
|
||||||
- docker-ce
|
|
||||||
|
|
||||||
env:
|
|
||||||
- MAILU_VERSION=$TRAVIS_BRANCH
|
|
||||||
language: python
|
|
||||||
python:
|
|
||||||
- "3.6"
|
|
||||||
install:
|
|
||||||
- pip install -r tests/requirements.txt
|
|
||||||
- sudo curl -L https://github.com/docker/compose/releases/download/1.23.0-rc3/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
|
|
||||||
- sudo chmod +x /usr/local/bin/docker-compose
|
|
||||||
|
|
||||||
before_script:
|
|
||||||
- docker-compose -v
|
|
||||||
- docker-compose -f tests/build.yml build
|
|
||||||
- sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*'
|
|
||||||
|
|
||||||
script:
|
|
||||||
# test.py, test name and timeout between start and tests.
|
|
||||||
- python tests/compose/test.py core 1
|
|
||||||
- python tests/compose/test.py fetchmail 1
|
|
||||||
- travis_wait python tests/compose/test.py filters 10
|
|
||||||
- python tests/compose/test.py rainloop 1
|
|
||||||
- python tests/compose/test.py roundcube 1
|
|
||||||
- python tests/compose/test.py webdav 1
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
provider: script
|
|
||||||
script: bash tests/deploy.sh
|
|
||||||
on:
|
|
||||||
all_branches: true
|
|
||||||
condition: -n $DOCKER_UN
|
|
||||||
14
AUTHORS.md
14
AUTHORS.md
@@ -6,19 +6,7 @@ If you contribute time, code or resources to the project, feel free to add
|
|||||||
your name, pseudonym, and any contact information you feel is relevant to
|
your name, pseudonym, and any contact information you feel is relevant to
|
||||||
this file.
|
this file.
|
||||||
|
|
||||||
As it is almost impossible to distinguish code contributions from various
|
|
||||||
authors, all are considered equal contributors and all must agree with
|
|
||||||
any change in the software license.
|
|
||||||
|
|
||||||
Other contributors:
|
Other contributors:
|
||||||
|
|
||||||
- "Angedestenebres" - Tests on development version & Current version
|
- Angedestenebres - Tests on development version & Current version
|
||||||
- Stefan Auditor - German translation on POEditor.com
|
- Stefan Auditor - German translation on POEditor.com
|
||||||
- [Carlos Bernárdez](https://github.com/jkarlosb) - [[Contributions in Mailu]](https://github.com/Mailu/Mailu/commits?author=jkarlosb)
|
|
||||||
- Felipe Lubra - Portugese translation
|
|
||||||
- Mélanie Henry - German translation
|
|
||||||
- Maxime Saddok - French translation
|
|
||||||
- "ofthesun9" - French translation
|
|
||||||
- "SunMar" - Dutch translation
|
|
||||||
- "Marty Hou" - Chinese Simple translation
|
|
||||||
- [Thomas Sänger](https://github.com/HorayNarea) - German translation
|
|
||||||
|
|||||||
59
CHANGELOG.md
59
CHANGELOG.md
@@ -5,65 +5,6 @@ Notable changes to this project are documented in the current file. For more
|
|||||||
details about individual changes, see the Git log. You should read this before
|
details about individual changes, see the Git log. You should read this before
|
||||||
upgrading Freposte.io as some changes will include useful notes.
|
upgrading Freposte.io as some changes will include useful notes.
|
||||||
|
|
||||||
v1.5.1 - 2017-11-21
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
- Global: add a DNS-based instance count tracker, use the ``DISABLE_STATISTICS``
|
|
||||||
setting to disable it.
|
|
||||||
- Global: specify container dependencies in the Compose configuration, update
|
|
||||||
your ``docker-compose.yml``.
|
|
||||||
- Feature: add a *mail* TLS flavor that only enforces TLS for email connections.
|
|
||||||
- Feature: welcome emails, see the configuration for details
|
|
||||||
- Feature: end date for vacations, see the automatic reply page
|
|
||||||
- L10N: dutch loca is now available
|
|
||||||
- L10N: swedish loca is now available
|
|
||||||
- L10N: italian loca is now partially available
|
|
||||||
- L10N: chinese loca is now available
|
|
||||||
- Upstream: upgrade to Roundcube 1.3.3
|
|
||||||
- Enhancement: use the alpine image for redis
|
|
||||||
- Enhancement: use a dynamic worker count for Nginx
|
|
||||||
- Bug: fix the pop3 proxy
|
|
||||||
- Bug: fix DNS resolution bugs in the frontend
|
|
||||||
- Bug: fix Webdav authentication
|
|
||||||
- Bug: properly honor enabled features (imap and pop3) per user
|
|
||||||
|
|
||||||
v1.5.0 - 2017-11-05
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
- Global: clean the ``.env`` file and change many options, *make sure
|
|
||||||
that you download the latest ``.env`` and apply your settings when migrating.*
|
|
||||||
- Global: clean the Compose configuration, *make sure that you download the
|
|
||||||
latest ``docker-compose.yml`` when migrating.*
|
|
||||||
- Global: nginx is now a reverse proxy for HTTP, SMTP, IMAP and POP.
|
|
||||||
- Global: the new Rainloop webmail is available.
|
|
||||||
- Global: the mail stack now supports IPv6.
|
|
||||||
- Global: most images moved to Alpine.
|
|
||||||
- Global: the documentation moved to a Sphinx directory.
|
|
||||||
- Global: deprecate rmilter and use rspamd proxy instead.
|
|
||||||
- Feature: multiple TLS flavors are available, see the ``TLS_FLAVOR`` setting.
|
|
||||||
- Feature: alternative domains now act as a copy of a given domain.
|
|
||||||
- Feature: relay domains now act as a mail relay (e.g. for backup servers).
|
|
||||||
- Feature: the server now supports multiple public names, with letsencrypt.
|
|
||||||
- Feature: authentication tokens can be generated per client.
|
|
||||||
- Feature: the manage.py CLI has many options to import and manage a setup.
|
|
||||||
- Feature: add overrides for the Postfix configuration.
|
|
||||||
- Feature: allow to keep or discard forwarded messages.
|
|
||||||
- Feature: make password encryption scheme configurable.
|
|
||||||
- Feature: make DMARC rua configurable.
|
|
||||||
- Feature: Clamav may now be disabled completely.
|
|
||||||
- Feature: support a configurable recipient delimiter for address extension.
|
|
||||||
- Feature: the admin interface points to the webmail and a configurable site.
|
|
||||||
- L10N: portugese loca is now available
|
|
||||||
- Upstream: upgrade to Roundcube 1.3.2
|
|
||||||
- Upstream: upgrade to Rainloop 1.11.3
|
|
||||||
- Upstream: upgrade to Dovecot 2.2.33
|
|
||||||
- Upstream: upgrade to Postfix 3.2.4
|
|
||||||
- Bug: the Postfix queue is now persisted.
|
|
||||||
- Bug: certbot now handle renewal properly.
|
|
||||||
- Bug: fix sender and recipient restrictions for antispam features.
|
|
||||||
- Bug: webmails now handle large attachments.
|
|
||||||
- Bug: dhparam are now generated properly on the frontend.
|
|
||||||
|
|
||||||
v1.4.0 - 2017-02-12
|
v1.4.0 - 2017-02-12
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# Contributor Covenant Code of Conduct
|
|
||||||
|
|
||||||
## Our Pledge
|
|
||||||
|
|
||||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
|
||||||
|
|
||||||
## Our Standards
|
|
||||||
|
|
||||||
Examples of behavior that contributes to creating a positive environment include:
|
|
||||||
|
|
||||||
* Using welcoming and inclusive language
|
|
||||||
* Being respectful of differing viewpoints and experiences
|
|
||||||
* Gracefully accepting constructive criticism
|
|
||||||
* Focusing on what is best for the community
|
|
||||||
* Showing empathy towards other community members
|
|
||||||
* Accepting that not all community members share our view of the project
|
|
||||||
|
|
||||||
Examples of unacceptable behavior by participants include:
|
|
||||||
|
|
||||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
|
||||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
|
||||||
* Public or private harassment
|
|
||||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
|
||||||
|
|
||||||
## Our Responsibilities
|
|
||||||
|
|
||||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
|
||||||
|
|
||||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
|
||||||
|
|
||||||
## Enforcement
|
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at team@mailu.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
|
||||||
|
|
||||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
|
||||||
|
|
||||||
## Attribution
|
|
||||||
|
|
||||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
|
|
||||||
|
|
||||||
[homepage]: http://contributor-covenant.org
|
|
||||||
[version]: http://contributor-covenant.org/version/1/4/
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
This project is open source, and your contributions are all welcome. There are mostly three different ways one can contribute to the project:
|
|
||||||
|
|
||||||
1. use Mailu, either on test or on production instances, and report meaningful bugs when you find some;
|
|
||||||
2. contribute code and/or configuration to the repository (see [the development guidelines](https://mailu.io/contributors/guide.html) for details);
|
|
||||||
3. contribute localization to your native language (see [the localization docs](https://mailu.io/contributors/localization.html) for details);
|
|
||||||
|
|
||||||
Either way, keep in mind that the code you write or the translation you produce muts be licensed under the same conditions as the project itself. Additionally, all contributors are considered equal co-authors of the project.
|
|
||||||
57
README.md
57
README.md
@@ -1,16 +1,28 @@
|
|||||||
<p align="leftr"><img src="docs/assets/logomark.png" alt="Mailu" height="200px"></p>
|

|
||||||
|
|
||||||
|
[Join us and chat about the project.](https://riot.im/app/#/room/#mailu:tedomum.net)
|
||||||
|
|
||||||
Mailu is a simple yet full-featured mail server as a set of Docker images.
|
Mailu
|
||||||
It is free software (both as in free beer and as in free speech), open to
|
=====
|
||||||
suggestions and external contributions. The project aims at providing people
|
|
||||||
with an easily setup, easily maintained and full-featured mail server while
|
|
||||||
not shipping proprietary software nor unrelated features often found in
|
|
||||||
popular groupware.
|
|
||||||
|
|
||||||
Most of the documentation is available on our [Website](https://mailu.io),
|
*This project used to be named Freeposte.io, the name was changed back in
|
||||||
you can also [try our demo server](https://mailu.io/master/demo.html)
|
October 2016.*
|
||||||
before setting up your own, and come [talk to us on Matrix](https://matrix.to/#/#mailu:tedomum.net).
|
|
||||||
|
Simple yet full-featured mail server as a set of Docker images.
|
||||||
|
The idea behing Mailu is identical to motivations that led to poste.io:
|
||||||
|
providing a simple and maintainable mail server that is painless to manage and
|
||||||
|
does not require more resources than necessary.
|
||||||
|
|
||||||
|
People from poste.io did an amazing job at accomplishing this ; any company
|
||||||
|
looking for a serious yet simple mail server with professional support should
|
||||||
|
turn to them.
|
||||||
|
|
||||||
|
This project is meant for free software supporters and hackers to reach the
|
||||||
|
same level of functionality and still be able to host a complete mail server
|
||||||
|
at little cost while running only FOSS, applying the KISS principle and being
|
||||||
|
able to fine-tune some details if needed.
|
||||||
|
|
||||||
|
[Try it out on our demo server](https://github.com/mail-u/mailu/wiki/Demo-server).
|
||||||
|
|
||||||
Features
|
Features
|
||||||
========
|
========
|
||||||
@@ -18,15 +30,28 @@ Features
|
|||||||
Main features include:
|
Main features include:
|
||||||
|
|
||||||
- **Standard email server**, IMAP and IMAP+, SMTP and Submission
|
- **Standard email server**, IMAP and IMAP+, SMTP and Submission
|
||||||
- **Advanced email features**, aliases, domain aliases, custom routing
|
- **Web access**, multiple Webmails and adminitration interface
|
||||||
- **Web access**, multiple Webmails and administration interface
|
|
||||||
- **User features**, aliases, auto-reply, auto-forward, fetched accounts
|
- **User features**, aliases, auto-reply, auto-forward, fetched accounts
|
||||||
- **Admin features**, global admins, announcements, per-domain delegation, quotas
|
- **Admin features**, global admins, per-domain delegation, quotas
|
||||||
- **Security**, enforced TLS, Letsencrypt!, outgoing DKIM, anti-virus scanner
|
- **Security**, enforced TLS, outgoing DKIM, anti-virus scanner
|
||||||
- **Antispam**, auto-learn, greylisting, DMARC and SPF
|
- **Antispam**, auto-learn, greylisting, DMARC and SPF
|
||||||
- **Freedom**, all FOSS components, no tracker included
|
- **Freedom**, all FOSS components, no tracker included
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
Running a mail server
|
||||||
|
=====================
|
||||||
|
|
||||||
|
Mailu runs on top of Docker for easy packaging and upgrades. All you need
|
||||||
|
is a proper system with Docker and Compose installed, then simply download
|
||||||
|
the ``docker-compose.yml`` and sample ``.env``, tune them to your needs and
|
||||||
|
fire up the mail server:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
For a detailed walktrough, see the [Setup Guide](https://github.com/mail-u/mailu/wiki/Setup-Guide).
|
||||||
|
|
||||||
Contributing
|
Contributing
|
||||||
============
|
============
|
||||||
@@ -35,3 +60,5 @@ Mailu is free software, open to suggestions and contributions. All
|
|||||||
components are free software and compatible with the MIT license. All
|
components are free software and compatible with the MIT license. All
|
||||||
specific configuration files, Dockerfiles and code are placed under the
|
specific configuration files, Dockerfiles and code are placed under the
|
||||||
MIT license.
|
MIT license.
|
||||||
|
|
||||||
|
For details, see the [Contributor Guide](https://github.com/mail-u/mailu/wiki/Contributors-Guide).
|
||||||
|
|||||||
16
admin/Dockerfile
Normal file
16
admin/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3
|
||||||
|
|
||||||
|
RUN mkdir -p /app
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY mailu ./mailu
|
||||||
|
COPY migrations ./migrations
|
||||||
|
COPY manage.py .
|
||||||
|
COPY start.sh /start.sh
|
||||||
|
|
||||||
|
RUN pybabel compile -d mailu/translations
|
||||||
|
|
||||||
|
CMD ["/start.sh"]
|
||||||
73
admin/mailu/__init__.py
Normal file
73
admin/mailu/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import flask
|
||||||
|
import flask_sqlalchemy
|
||||||
|
import flask_bootstrap
|
||||||
|
import flask_login
|
||||||
|
import flask_script
|
||||||
|
import flask_migrate
|
||||||
|
import flask_babel
|
||||||
|
|
||||||
|
import os
|
||||||
|
import docker
|
||||||
|
|
||||||
|
from apscheduler.schedulers import background
|
||||||
|
|
||||||
|
|
||||||
|
# Create application
|
||||||
|
app = flask.Flask(__name__, static_url_path='/admin/app_static')
|
||||||
|
|
||||||
|
default_config = {
|
||||||
|
'SQLALCHEMY_DATABASE_URI': 'sqlite:////data/main.db',
|
||||||
|
'SQLALCHEMY_TRACK_MODIFICATIONS': False,
|
||||||
|
'SECRET_KEY': 'changeMe',
|
||||||
|
'DOCKER_SOCKET': 'unix:///var/run/docker.sock',
|
||||||
|
'HOSTNAME': 'mail.mailu.io',
|
||||||
|
'DOMAIN': 'mailu.io',
|
||||||
|
'POSTMASTER': 'postmaster',
|
||||||
|
'DEBUG': False,
|
||||||
|
'BOOTSTRAP_SERVE_LOCAL': True,
|
||||||
|
'DKIM_PATH': '/dkim/{domain}.{selector}.key',
|
||||||
|
'DKIM_SELECTOR': 'dkim',
|
||||||
|
'BABEL_DEFAULT_LOCALE': 'en',
|
||||||
|
'BABEL_DEFAULT_TIMEZONE': 'UTC',
|
||||||
|
'ENABLE_CERTBOT': False,
|
||||||
|
'CERTS_PATH': '/certs'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load configuration from the environment if available
|
||||||
|
for key, value in default_config.items():
|
||||||
|
app.config[key] = os.environ.get(key, value)
|
||||||
|
|
||||||
|
# Setup components
|
||||||
|
flask_bootstrap.Bootstrap(app)
|
||||||
|
db = flask_sqlalchemy.SQLAlchemy(app)
|
||||||
|
migrate = flask_migrate.Migrate(app, db)
|
||||||
|
login_manager = flask_login.LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
babel = flask_babel.Babel(app)
|
||||||
|
translations = list(map(str, babel.list_translations()))
|
||||||
|
scheduler = background.BackgroundScheduler()
|
||||||
|
|
||||||
|
# Manager commnad
|
||||||
|
manager = flask_script.Manager(app)
|
||||||
|
manager.add_command('db', flask_migrate.MigrateCommand)
|
||||||
|
|
||||||
|
# Task scheduling
|
||||||
|
if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
# Babel configuration
|
||||||
|
@babel.localeselector
|
||||||
|
def get_locale():
|
||||||
|
return flask.request.accept_languages.best_match(translations)
|
||||||
|
|
||||||
|
# Certbot configuration
|
||||||
|
if app.config['ENABLE_CERTBOT']:
|
||||||
|
from mailu import certbot
|
||||||
|
|
||||||
|
# Finally setup the blueprint and redirect /
|
||||||
|
from mailu import admin
|
||||||
|
app.register_blueprint(admin.app, url_prefix='/admin')
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return flask.redirect(flask.url_for("admin.index"))
|
||||||
31
admin/mailu/admin/__init__.py
Normal file
31
admin/mailu/admin/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
from mailu import login_manager, db
|
||||||
|
|
||||||
|
import flask_login
|
||||||
|
|
||||||
|
|
||||||
|
app = Blueprint(
|
||||||
|
'admin', __name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static')
|
||||||
|
|
||||||
|
# Import models
|
||||||
|
from mailu.admin import models
|
||||||
|
|
||||||
|
# Register the login components
|
||||||
|
login_manager.login_view = "admin.login"
|
||||||
|
login_manager.user_loader(models.User.query.get)
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_user():
|
||||||
|
return dict(current_user=flask_login.current_user)
|
||||||
|
|
||||||
|
# Import views
|
||||||
|
from mailu.admin.views import \
|
||||||
|
admins, \
|
||||||
|
managers, \
|
||||||
|
base, \
|
||||||
|
aliases, \
|
||||||
|
users, \
|
||||||
|
domains, \
|
||||||
|
fetches
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
from mailu import models
|
from mailu.admin import db, models, forms
|
||||||
from mailu.ui import forms
|
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
import flask_login
|
import flask_login
|
||||||
@@ -6,7 +6,6 @@ import flask_login
|
|||||||
import flask_wtf
|
import flask_wtf
|
||||||
import re
|
import re
|
||||||
|
|
||||||
LOCALPART_REGEX = "^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+$"
|
|
||||||
|
|
||||||
class DestinationField(fields.SelectMultipleField):
|
class DestinationField(fields.SelectMultipleField):
|
||||||
""" Allow for multiple emails selection from current user choices and
|
""" Allow for multiple emails selection from current user choices and
|
||||||
@@ -48,62 +47,25 @@ class DomainForm(flask_wtf.FlaskForm):
|
|||||||
max_users = fields_.IntegerField(_('Maximum user count'), default=10)
|
max_users = fields_.IntegerField(_('Maximum user count'), default=10)
|
||||||
max_aliases = fields_.IntegerField(_('Maximum alias count'), default=10)
|
max_aliases = fields_.IntegerField(_('Maximum alias count'), default=10)
|
||||||
max_quota_bytes = fields_.IntegerSliderField(_('Maximum user quota'), default=0)
|
max_quota_bytes = fields_.IntegerSliderField(_('Maximum user quota'), default=0)
|
||||||
signup_enabled = fields.BooleanField(_('Enable sign-up'), default=False)
|
|
||||||
comment = fields.StringField(_('Comment'))
|
comment = fields.StringField(_('Comment'))
|
||||||
submit = fields.SubmitField(_('Save'))
|
|
||||||
|
|
||||||
|
|
||||||
class DomainSignupForm(flask_wtf.FlaskForm):
|
|
||||||
name = fields.StringField(_('Domain name'), [validators.DataRequired()])
|
|
||||||
localpart = fields.StringField(_('Initial admin'), [validators.DataRequired()])
|
|
||||||
pw = fields.PasswordField(_('Admin password'), [validators.DataRequired()])
|
|
||||||
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
|
||||||
captcha = flask_wtf.RecaptchaField()
|
|
||||||
submit = fields.SubmitField(_('Create'))
|
submit = fields.SubmitField(_('Create'))
|
||||||
|
|
||||||
|
|
||||||
class AlternativeForm(flask_wtf.FlaskForm):
|
|
||||||
name = fields.StringField(_('Alternative name'), [validators.DataRequired()])
|
|
||||||
submit = fields.SubmitField(_('Save'))
|
|
||||||
|
|
||||||
|
|
||||||
class RelayForm(flask_wtf.FlaskForm):
|
|
||||||
name = fields.StringField(_('Relayed domain name'), [validators.DataRequired()])
|
|
||||||
smtp = fields.StringField(_('Remote host'))
|
|
||||||
comment = fields.StringField(_('Comment'))
|
|
||||||
submit = fields.SubmitField(_('Save'))
|
|
||||||
|
|
||||||
|
|
||||||
class UserForm(flask_wtf.FlaskForm):
|
class UserForm(flask_wtf.FlaskForm):
|
||||||
localpart = fields.StringField(_('E-mail'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)])
|
localpart = fields.StringField(_('E-mail'), [validators.DataRequired()])
|
||||||
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
|
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
|
||||||
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
||||||
quota_bytes = fields_.IntegerSliderField(_('Quota'), default=1000000000)
|
quota_bytes = fields_.IntegerSliderField(_('Quota'), default=1000000000)
|
||||||
enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True)
|
enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True)
|
||||||
enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True)
|
enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True)
|
||||||
comment = fields.StringField(_('Comment'))
|
comment = fields.StringField(_('Comment'))
|
||||||
enabled = fields.BooleanField(_('Enabled'), default=True)
|
|
||||||
submit = fields.SubmitField(_('Save'))
|
submit = fields.SubmitField(_('Save'))
|
||||||
|
|
||||||
|
|
||||||
class UserSignupForm(flask_wtf.FlaskForm):
|
|
||||||
localpart = fields.StringField(_('Email address'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)])
|
|
||||||
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
|
|
||||||
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
|
|
||||||
submit = fields.SubmitField(_('Sign up'))
|
|
||||||
|
|
||||||
class UserSignupFormCaptcha(UserSignupForm):
|
|
||||||
captcha = flask_wtf.RecaptchaField()
|
|
||||||
|
|
||||||
class UserSettingsForm(flask_wtf.FlaskForm):
|
class UserSettingsForm(flask_wtf.FlaskForm):
|
||||||
displayed_name = fields.StringField(_('Displayed name'))
|
displayed_name = fields.StringField(_('Displayed name'))
|
||||||
spam_enabled = fields.BooleanField(_('Enable spam filter'))
|
spam_enabled = fields.BooleanField(_('Enable spam filter'))
|
||||||
spam_threshold = fields_.IntegerSliderField(_('Spam filter tolerance'))
|
spam_threshold = fields_.IntegerSliderField(_('Spam filter threshold'))
|
||||||
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()]
|
|
||||||
)
|
|
||||||
submit = fields.SubmitField(_('Save settings'))
|
submit = fields.SubmitField(_('Save settings'))
|
||||||
|
|
||||||
|
|
||||||
@@ -113,35 +75,29 @@ class UserPasswordForm(flask_wtf.FlaskForm):
|
|||||||
submit = fields.SubmitField(_('Update password'))
|
submit = fields.SubmitField(_('Update password'))
|
||||||
|
|
||||||
|
|
||||||
|
class UserForwardForm(flask_wtf.FlaskForm):
|
||||||
|
forward_enabled = fields.BooleanField(_('Enable forwarding'))
|
||||||
|
forward_destination = fields.StringField(
|
||||||
|
_('Destination'), [validators.Optional(), validators.Email()]
|
||||||
|
)
|
||||||
|
submit = fields.SubmitField(_('Update'))
|
||||||
|
|
||||||
|
|
||||||
class UserReplyForm(flask_wtf.FlaskForm):
|
class UserReplyForm(flask_wtf.FlaskForm):
|
||||||
reply_enabled = fields.BooleanField(_('Enable automatic reply'))
|
reply_enabled = fields.BooleanField(_('Enable automatic reply'))
|
||||||
reply_subject = fields.StringField(_('Reply subject'))
|
reply_subject = fields.StringField(_('Reply subject'))
|
||||||
reply_body = fields.StringField(_('Reply body'),
|
reply_body = fields.StringField(_('Reply body'),
|
||||||
widget=widgets.TextArea())
|
widget=widgets.TextArea())
|
||||||
reply_startdate = fields.html5.DateField(_('Start of vacation'))
|
|
||||||
reply_enddate = fields.html5.DateField(_('End of vacation'))
|
|
||||||
submit = fields.SubmitField(_('Update'))
|
submit = fields.SubmitField(_('Update'))
|
||||||
|
|
||||||
|
|
||||||
class TokenForm(flask_wtf.FlaskForm):
|
|
||||||
displayed_password = fields.StringField(
|
|
||||||
_('Your token (write it down, as it will never be displayed again)')
|
|
||||||
)
|
|
||||||
raw_password = fields.HiddenField([validators.DataRequired()])
|
|
||||||
comment = fields.StringField(_('Comment'))
|
|
||||||
ip = fields.StringField(
|
|
||||||
_('Authorized IP'), [validators.Optional(), validators.IPAddress()]
|
|
||||||
)
|
|
||||||
submit = fields.SubmitField(_('Save'))
|
|
||||||
|
|
||||||
|
|
||||||
class AliasForm(flask_wtf.FlaskForm):
|
class AliasForm(flask_wtf.FlaskForm):
|
||||||
localpart = fields.StringField(_('Alias'), [validators.DataRequired()])
|
localpart = fields.StringField(_('Alias'), [validators.DataRequired()])
|
||||||
wildcard = fields.BooleanField(
|
wildcard = fields.BooleanField(
|
||||||
_('Use SQL LIKE Syntax (e.g. for catch-all aliases)'))
|
_('Use SQL LIKE Syntax (e.g. for catch-all aliases)'))
|
||||||
destination = DestinationField(_('Destination'))
|
destination = DestinationField(_('Destination'))
|
||||||
comment = fields.StringField(_('Comment'))
|
comment = fields.StringField(_('Comment'))
|
||||||
submit = fields.SubmitField(_('Save'))
|
submit = fields.SubmitField(_('Create'))
|
||||||
|
|
||||||
|
|
||||||
class AdminForm(flask_wtf.FlaskForm):
|
class AdminForm(flask_wtf.FlaskForm):
|
||||||
227
admin/mailu/admin/models.py
Normal file
227
admin/mailu/admin/models.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
from mailu.admin import db, dkim
|
||||||
|
from mailu import app
|
||||||
|
|
||||||
|
from sqlalchemy.ext import declarative
|
||||||
|
from passlib import context
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
|
||||||
|
|
||||||
|
# Many-to-many association table for domain managers
|
||||||
|
managers = db.Table('manager',
|
||||||
|
db.Column('domain_name', db.String(80), db.ForeignKey('domain.name')),
|
||||||
|
db.Column('user_email', db.String(255), db.ForeignKey('user.email'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CommaSeparatedList(db.TypeDecorator):
|
||||||
|
""" Stores a list as a comma-separated string, compatible with Postfix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
impl = db.String
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
if type(value) is not list:
|
||||||
|
raise TypeError("Shoud be a list")
|
||||||
|
for item in value:
|
||||||
|
if "," in item:
|
||||||
|
raise ValueError("No item should contain a comma")
|
||||||
|
return ",".join(value)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return filter(bool, value.split(","))
|
||||||
|
|
||||||
|
|
||||||
|
class Base(db.Model):
|
||||||
|
""" Base class for all models
|
||||||
|
"""
|
||||||
|
|
||||||
|
__abstract__ = True
|
||||||
|
|
||||||
|
created_at = db.Column(db.Date, nullable=False, default=datetime.now)
|
||||||
|
updated_at = db.Column(db.Date, nullable=True, onupdate=datetime.now)
|
||||||
|
comment = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Domain(Base):
|
||||||
|
""" A DNS domain that has mail addresses associated to it.
|
||||||
|
"""
|
||||||
|
__tablename__ = "domain"
|
||||||
|
|
||||||
|
name = db.Column(db.String(80), primary_key=True, nullable=False)
|
||||||
|
managers = db.relationship('User', secondary=managers,
|
||||||
|
backref=db.backref('manager_of'), lazy='dynamic')
|
||||||
|
max_users = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
max_aliases = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
max_quota_bytes = db.Column(db.Integer(), nullable=False, default=0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dkim_key(self):
|
||||||
|
file_path = app.config["DKIM_PATH"].format(
|
||||||
|
domain=self.name, selector=app.config["DKIM_SELECTOR"])
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
with open(file_path, "rb") as handle:
|
||||||
|
return handle.read()
|
||||||
|
|
||||||
|
@dkim_key.setter
|
||||||
|
def dkim_key(self, value):
|
||||||
|
file_path = app.config["DKIM_PATH"].format(
|
||||||
|
domain=self.name, selector=app.config["DKIM_SELECTOR"])
|
||||||
|
with open(file_path, "wb") as handle:
|
||||||
|
handle.write(value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dkim_publickey(self):
|
||||||
|
dkim_key = self.dkim_key
|
||||||
|
if dkim_key:
|
||||||
|
return dkim.strip_key(self.dkim_key).decode("utf8")
|
||||||
|
|
||||||
|
def generate_dkim_key(self):
|
||||||
|
self.dkim_key = dkim.gen_key()
|
||||||
|
|
||||||
|
def has_email(self, localpart):
|
||||||
|
for email in self.users + self.aliases:
|
||||||
|
if email.localpart == localpart:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
try:
|
||||||
|
return self.name == other.name
|
||||||
|
except AttributeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Email(object):
|
||||||
|
""" Abstraction for an email address (localpart and domain).
|
||||||
|
"""
|
||||||
|
|
||||||
|
localpart = db.Column(db.String(80), nullable=False)
|
||||||
|
|
||||||
|
@declarative.declared_attr
|
||||||
|
def domain_name(cls):
|
||||||
|
return db.Column(db.String(80), db.ForeignKey(Domain.name),
|
||||||
|
nullable=False)
|
||||||
|
|
||||||
|
# This field is redundant with both localpart and domain name.
|
||||||
|
# It is however very useful for quick lookups without joining tables,
|
||||||
|
# especially when the mail server il reading the database.
|
||||||
|
@declarative.declared_attr
|
||||||
|
def email(cls):
|
||||||
|
updater = lambda context: "{0}@{1}".format(
|
||||||
|
context.current_parameters["localpart"],
|
||||||
|
context.current_parameters["domain_name"],
|
||||||
|
)
|
||||||
|
return db.Column(db.String(255),
|
||||||
|
primary_key=True, nullable=False,
|
||||||
|
default=updater)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, Email):
|
||||||
|
""" A user is an email address that has a password to access a mailbox.
|
||||||
|
"""
|
||||||
|
__tablename__ = "user"
|
||||||
|
|
||||||
|
domain = db.relationship(Domain,
|
||||||
|
backref=db.backref('users', cascade='all, delete-orphan'))
|
||||||
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
quota_bytes = db.Column(db.Integer(), nullable=False, default=10**9)
|
||||||
|
global_admin = db.Column(db.Boolean(), nullable=False, default=False)
|
||||||
|
|
||||||
|
# Features
|
||||||
|
enable_imap = db.Column(db.Boolean(), nullable=False, default=True)
|
||||||
|
enable_pop = db.Column(db.Boolean(), nullable=False, default=True)
|
||||||
|
|
||||||
|
# Filters
|
||||||
|
forward_enabled = db.Column(db.Boolean(), nullable=False, default=False)
|
||||||
|
forward_destination = db.Column(db.String(255), nullable=True, default=None)
|
||||||
|
reply_enabled = db.Column(db.Boolean(), nullable=False, default=False)
|
||||||
|
reply_subject = db.Column(db.String(255), nullable=True, default=None)
|
||||||
|
reply_body = db.Column(db.Text(), nullable=True, default=None)
|
||||||
|
|
||||||
|
# Settings
|
||||||
|
displayed_name = db.Column(db.String(160), nullable=False, default="")
|
||||||
|
spam_enabled = db.Column(db.Boolean(), nullable=False, default=True)
|
||||||
|
spam_threshold = db.Column(db.Integer(), nullable=False, default=80.0)
|
||||||
|
|
||||||
|
# Flask-login attributes
|
||||||
|
is_authenticated = True
|
||||||
|
is_active = True
|
||||||
|
is_anonymous = False
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
pw_context = context.CryptContext(
|
||||||
|
["sha512_crypt", "sha256_crypt", "md5_crypt"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
reference = re.match('({[^}]+})?(.*)', self.password).group(2)
|
||||||
|
return User.pw_context.verify(password, reference)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password = '{SHA512-CRYPT}' + User.pw_context.encrypt(password)
|
||||||
|
|
||||||
|
def get_managed_domains(self):
|
||||||
|
if self.global_admin:
|
||||||
|
return Domain.query.all()
|
||||||
|
else:
|
||||||
|
return self.manager_of
|
||||||
|
|
||||||
|
def get_managed_emails(self, include_aliases=True):
|
||||||
|
emails = []
|
||||||
|
for domain in self.get_managed_domains():
|
||||||
|
emails.extend(domain.users)
|
||||||
|
if include_aliases:
|
||||||
|
emails.extend(domain.aliases)
|
||||||
|
return emails
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def login(cls, email, password):
|
||||||
|
user = cls.query.get(email)
|
||||||
|
return user if (user and user.check_password(password)) else None
|
||||||
|
|
||||||
|
|
||||||
|
class Alias(Base, Email):
|
||||||
|
""" An alias is an email address that redirects to some destination.
|
||||||
|
"""
|
||||||
|
__tablename__ = "alias"
|
||||||
|
|
||||||
|
domain = db.relationship(Domain,
|
||||||
|
backref=db.backref('aliases', cascade='all, delete-orphan'))
|
||||||
|
wildcard = db.Column(db.Boolean(), nullable=False, default=False)
|
||||||
|
destination = db.Column(CommaSeparatedList, nullable=False, default=[])
|
||||||
|
|
||||||
|
|
||||||
|
class Fetch(Base):
|
||||||
|
""" A fetched account is a repote POP/IMAP account fetched into a local
|
||||||
|
account.
|
||||||
|
"""
|
||||||
|
__tablename__ = "fetch"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
|
user_email = db.Column(db.String(255), db.ForeignKey(User.email),
|
||||||
|
nullable=False)
|
||||||
|
user = db.relationship(User,
|
||||||
|
backref=db.backref('fetches', cascade='all, delete-orphan'))
|
||||||
|
protocol = db.Column(db.Enum('imap', 'pop3'), nullable=False)
|
||||||
|
host = db.Column(db.String(255), nullable=False)
|
||||||
|
port = db.Column(db.Integer(), nullable=False)
|
||||||
|
tls = db.Column(db.Boolean(), nullable=False)
|
||||||
|
username = db.Column(db.String(255), nullable=False)
|
||||||
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
keep = db.Column(db.Boolean(), nullable=False)
|
||||||
|
last_check = db.Column(db.DateTime, nullable=True)
|
||||||
|
error = db.Column(db.String(1023), nullable=True)
|
||||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
@@ -4,8 +4,7 @@
|
|||||||
{% trans %}Add a global administrator{% endtrans %}
|
{% trans %}Add a global administrator{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block box_content %}
|
||||||
{% call macros.box() %}
|
|
||||||
<form class="form" method="post" role="form">
|
<form class="form" method="post" role="form">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
{{ macros.form_field(form.admin, id='admin') }}
|
{{ macros.form_field(form.admin, id='admin') }}
|
||||||
@@ -14,5 +13,4 @@
|
|||||||
$("#admin").select2();
|
$("#admin").select2();
|
||||||
</script>
|
</script>
|
||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
30
admin/mailu/admin/templates/admin/list.html
Normal file
30
admin/mailu/admin/templates/admin/list.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans %}Global administrators{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_action %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('.admin_create') }}">
|
||||||
|
{% trans %}Add administrator{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block box %}
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}Actions{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Email{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
{% 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 %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -8,8 +8,7 @@
|
|||||||
{{ domain }}
|
{{ domain }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block box_content %}
|
||||||
{% call macros.box() %}
|
|
||||||
<form class="form" method="post" role="form">
|
<form class="form" method="post" role="form">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
{{ macros.form_field(form.localpart, append='<span class="input-group-addon">@'+domain.name+'</span>') }}
|
{{ macros.form_field(form.localpart, append='<span class="input-group-addon">@'+domain.name+'</span>') }}
|
||||||
@@ -24,5 +23,4 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
41
admin/mailu/admin/templates/alias/list.html
Normal file
41
admin/mailu/admin/templates/alias/list.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans %}Alias list{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block subtitle %}
|
||||||
|
{{ domain.name }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_action %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('.alias_create', domain_name=domain.name) }}">{% trans %}Add alias{% endtrans %}</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block box %}
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}Actions{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Email{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Destination{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Comment{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Created{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Last edit{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
{% 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>
|
||||||
|
<a href="{{ url_for('.alias_delete', alias=alias.email) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>
|
||||||
|
</td>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,13 +4,15 @@
|
|||||||
{% trans %}Public announcement{% endtrans %}
|
{% trans %}Public announcement{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block subtitle %}
|
||||||
{% call macros.box() %}
|
{% trans %}from{% endtrans %} {{ from_address }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block box_content %}
|
||||||
<form class="form" method="post" role="form">
|
<form class="form" method="post" role="form">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
{{ macros.form_field(form.announcement_subject) }}
|
{{ macros.form_field(form.announcement_subject) }}
|
||||||
{{ macros.form_field(form.announcement_body, rows=10) }}
|
{{ macros.form_field(form.announcement_body, rows=10) }}
|
||||||
{{ macros.form_field(form.submit) }}
|
{{ macros.form_field(form.submit) }}
|
||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block styles %}
|
{% block styles %}
|
||||||
{{super()}}
|
{{super()}}
|
||||||
<link rel="stylesheet" href="{{ url_for('.static', filename='select2/css/select2.min.css') }}">
|
<link rel="stylesheet" href="{{ url_for('.static', filename='select2/css/select2.min.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('.static', filename='fontawesome/css/font-awesome.min.css') }}">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css">
|
||||||
<link rel="stylesheet" href="{{ url_for('.static', filename='adminlte/css/AdminLTE.min.css') }}">
|
<link rel="stylesheet" href="{{ url_for('.static', filename='adminlte/css/AdminLTE.min.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('.static', filename='adminlte/css/skin-blue.min.css') }}">
|
<link rel="stylesheet" href="{{ url_for('.static', filename='adminlte/css/skin-blue.min.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('.static', filename='app.css') }}">
|
<link rel="stylesheet" href="{{ url_for('.static', filename='app.css') }}">
|
||||||
@@ -29,7 +29,7 @@ class="hold-transition skin-blue sidebar-mini"
|
|||||||
{% block navbar %}
|
{% block navbar %}
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
<a href="/admin/" class="logo">
|
<a href="/admin/" class="logo">
|
||||||
<span class="logo-lg">{{ config["SITENAME"] }}</span>
|
<span class="logo-lg">Mailu</span>
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
<aside class="main-sidebar">
|
<aside class="main-sidebar">
|
||||||
@@ -53,7 +53,22 @@ class="hold-transition skin-blue sidebar-mini"
|
|||||||
|
|
||||||
<section class="content">
|
<section class="content">
|
||||||
{{ utils.flashed_messages(container=False) }}
|
{{ utils.flashed_messages(container=False) }}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12">
|
||||||
|
<div class="box">
|
||||||
|
{% block box %}
|
||||||
|
<div class="box-header">
|
||||||
|
{% block box_title %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
{% block box_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<footer class="main-footer">
|
<footer class="main-footer">
|
||||||
@@ -8,9 +8,7 @@
|
|||||||
{{ action }}
|
{{ action }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block box_content %}
|
||||||
{% call macros.box(theme="warning") %}
|
|
||||||
<p>{% trans action %}You are about to {{ action }}. Please confirm your action.{% endtrans %}</p>
|
<p>{% trans action %}You are about to {{ action }}. Please confirm your action.{% endtrans %}</p>
|
||||||
{{ macros.form(form) }}
|
{{ macros.form(form) }}
|
||||||
{% endcall %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
{{ action }}
|
{{ action }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block box_content %}
|
||||||
<p>{% trans action %}An error occurred while talking to the Docker server.{% endtrans %}</p>
|
<p>{% trans action %}An error occurred while talking to the Docker server.{% endtrans %}</p>
|
||||||
<pre>{{ error }}</pre>
|
<pre>{{ error }}</pre>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -4,8 +4,7 @@
|
|||||||
{% trans %}New domain{% endtrans %}
|
{% trans %}New domain{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block box_content %}
|
||||||
{% call macros.box() %}
|
|
||||||
<form class="form" method="post" role="form">
|
<form class="form" method="post" role="form">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
{{ macros.form_field(form.name) }}
|
{{ macros.form_field(form.name) }}
|
||||||
@@ -13,9 +12,7 @@
|
|||||||
{{ macros.form_field(form.max_quota_bytes, step=1000000000, max=50000000000,
|
{{ macros.form_field(form.max_quota_bytes, step=1000000000, max=50000000000,
|
||||||
prepend='<span class="input-group-addon"><span id="quota">'+((form.max_quota_bytes.data//1000000000).__str__() if form.max_quota_bytes.data else '∞')+'</span> GiB</span>',
|
prepend='<span class="input-group-addon"><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);') }}
|
oninput='$("#quota").text(this.value == 0 ? "∞" : this.value/1000000000);') }}
|
||||||
{{ macros.form_field(form.signup_enabled) }}
|
|
||||||
{{ macros.form_field(form.comment) }}
|
{{ macros.form_field(form.comment) }}
|
||||||
{{ macros.form_field(form.submit) }}
|
{{ macros.form_field(form.submit) }}
|
||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
50
admin/mailu/admin/templates/domain/details.html
Normal file
50
admin/mailu/admin/templates/domain/details.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans %}Domain details{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block subtitle %}
|
||||||
|
{{ domain.name }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_action %}
|
||||||
|
{% if current_user.global_admin %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for(".domain_genkeys", domain_name=domain.name) }}">{% trans %}Regenerate keys{% endtrans %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block box %}
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}Domain name{% endtrans %}</th>
|
||||||
|
<td>{{ domain.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}DNS MX entry{% endtrans %}</th>
|
||||||
|
<td><pre>{{ domain.name }}. 600 IN MX 10 {{ config["HOSTNAME"] }}.</pre></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}DNS SPF entries{% endtrans %}</th>
|
||||||
|
<td><pre>
|
||||||
|
{{ domain.name }}. 600 IN TXT "v=spf1 mx a:{{ config["HOSTNAME"] }} -all"
|
||||||
|
{{ domain.name }}. 600 IN SPF "v=spf1 mx a:{{ config["HOSTNAME"] }} -all"</pre></td>
|
||||||
|
</tr>
|
||||||
|
{% 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>
|
||||||
|
</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 }}. IN 600 TXT "v=DKIM1; k=rsa; p={{ domain.dkim_publickey }}"</pre></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}DNS DMARC entry{% endtrans %}</th>
|
||||||
|
<td><pre>_dmarc.{{ domain.name }}. 600 IN TXT "v=DMARC1; p=reject; rua=mailto:{{ config["POSTMASTER"] }}@{{ config["DOMAIN"] }}; adkim=s; aspf=s"</pre></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
50
admin/mailu/admin/templates/domain/list.html
Normal file
50
admin/mailu/admin/templates/domain/list.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans %}Domain list{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_action %}
|
||||||
|
{% if current_user.global_admin %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('.domain_create') }}">{% trans %}New domain{% endtrans %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block box %}
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}Actions{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Manage{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Domain name{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Mailbox count{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Alias count{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Comment{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Created{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Last edit{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
{% 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>
|
||||||
|
{% if current_user.global_admin %}
|
||||||
|
<a href="{{ url_for('.domain_edit', domain_name=domain.name) }}" title="{% trans %}Edit{% endtrans %}"><i class="fa fa-pencil"></i></a>
|
||||||
|
<a href="{{ url_for('.domain_delete', domain_name=domain.name) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('.user_list', domain_name=domain.name) }}" title="{% trans %}Users{% endtrans %}"><i class="fa fa-envelope-o"></i></a>
|
||||||
|
<a href="{{ url_for('.alias_list', domain_name=domain.name) }}" title="{% trans %}Aliases{% endtrans %}"><i class="fa fa-at"></i></a>
|
||||||
|
<a href="{{ url_for('.manager_list', domain_name=domain.name) }}" title="{% trans %}Managers{% endtrans %}"><i class="fa fa-user"></i></a>
|
||||||
|
</td>
|
||||||
|
<td>{{ domain.name }}</td>
|
||||||
|
<td>{{ domain.users | count }} / {{ domain.max_users or '∞' }}</td>
|
||||||
|
<td>{{ domain.aliases | count }} / {{ domain.max_aliases or '∞' }}</td>
|
||||||
|
<td>{{ domain.comment or '' }}</td>
|
||||||
|
<td>{{ domain.created_at }}</td>
|
||||||
|
<td>{{ domain.updated_at or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "form.html" %}
|
{% extends "form.html" %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans %}Create an authentication token{% endtrans %}
|
{% trans %}Add a fetched account{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block subtitle %}
|
{% block subtitle %}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "fetch/create.html" %}
|
{% extends "form.html" %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans %}Update a fetched account{% endtrans %}
|
{% trans %}Update a fetched account{% endtrans %}
|
||||||
45
admin/mailu/admin/templates/fetch/list.html
Normal file
45
admin/mailu/admin/templates/fetch/list.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans %}Fetched accounts{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block subtitle %}
|
||||||
|
{{ user }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_action %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('.fetch_create', user_email=user.email) }}">{% trans %}Add an account{% endtrans %}</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block box %}
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}Actions{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Endpoint{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Username{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Keep emails{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Last check{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Status{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Created{% endtrans %}</th>
|
||||||
|
<th>{% trans %}Last edit{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
{% 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>
|
||||||
|
<a href="{{ url_for('.fetch_delete', fetch_id=fetch.id) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>
|
||||||
|
</td>
|
||||||
|
<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.error or '-' }}</td>
|
||||||
|
<td>{{ fetch.created_at }}</td>
|
||||||
|
<td>{{ fetch.updated_at or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block box_content %}
|
||||||
{% call macros.box() %}
|
|
||||||
{{ macros.form(form) }}
|
{{ macros.form(form) }}
|
||||||
{% endcall %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
13
admin/mailu/admin/templates/helpers.html
Normal file
13
admin/mailu/admin/templates/helpers.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% macro render_field(field, label_visible=true) -%}
|
||||||
|
<div class="form-group {% if field.errors %}has-error{% endif %} {{ kwargs.pop('class_', '') }}">
|
||||||
|
{% if field.type != 'HiddenField' and label_visible %}
|
||||||
|
<label for="{{ field.id }}" class="control-label">{{ field.label }}</label>
|
||||||
|
{% endif %}
|
||||||
|
{{ field(class_='form-control', **kwargs) }}
|
||||||
|
{% if field.errors %}
|
||||||
|
{% for e in field.errors %}
|
||||||
|
<p class="help-block">{{ e }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
5
admin/mailu/admin/templates/index.html
Normal file
5
admin/mailu/admin/templates/index.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends "general.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
Test
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user