diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 00000000..dba30722 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,446 @@ +name: CI +on: + push: + branches: + - staging + - testing + - '1.5' + - '1.6' + - '1.7' + - '1.8' + - master + # version tags, e.g. 1.7.1 + - '[1-9].[0-9].[0-9]' + # pre-releases, e.g. 1.8-pre1 + - 1.8-pre[0-9] + # test branches, e.g. test-debian + - test-* + +############################################### +# REQUIRED secrets +# DOCKER_UN: ${{ secrets.Docker_Login }} +# Username of docker login for pushing the images to repo $DOCKER_ORG and $DOCKER_ORG_TESTS +# DOCKER_PW: ${{ secrets.Docker_Password }} +# Password of docker login for pushing the images to repo $DOCKER_ORG and $DOCKER_ORG_TESTS +# DOCKER_ORG: ${{ secrets.DOCKER_ORG }} +# The docker repository where the images are pushed to. +# DOCKER_ORG_TESTS: ${{ secrets.DOCKER_ORG_TESTS }} +# The docker repository for test images. Only used for the branch TESTING (BORS try). +# Add the above secrets to your github repo to determine where the images will be pushed. +################################################ + +jobs: + build: + name: Build images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Extract branch name + shell: bash + run: | + echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + #For branch TESTING, we set the image tag to PR-xxxx + - name: Derive MAILU_VERSION for branch testing + if: ${{ env.BRANCH == 'testing' }} + shell: bash + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + echo "MAILU_VERSION=pr-${COMMIT_MESSAGE//[!0-9]/}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG_TESTS }}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for other branches than testing + if: ${{ env.BRANCH != 'testing' }} + shell: bash + env: + MAILU_BRANCH: ${{ env.BRANCH }} + run: | + echo "MAILU_VERSION=${{ env.MAILU_BRANCH }}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG }}" >> $GITHUB_ENV + - name: Create folder for storing images + run: | + sudo mkdir -p /images + sudo chmod 777 /images + - name: Configure images folder for caching + uses: actions/cache@v2 + with: + path: /images + key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }} + - name: Check docker-compose version + run: docker-compose -v + - name: Login docker + env: + DOCKER_UN: ${{ secrets.Docker_Login }} + DOCKER_PW: ${{ secrets.Docker_Password }} + DOCKER_ORG: ${{ env.DOCKER_ORG }} + run: echo "$DOCKER_PW" | docker login --username $DOCKER_UN --password-stdin + - name: Build all docker images + env: + MAILU_VERSION: ${{ env.MAILU_VERSION }} + TRAVIS_BRANCH: ${{ env.BRANCH }} + DOCKER_ORG: ${{ env.DOCKER_ORG }} + run: docker-compose -f tests/build.yml build + - name: Save all docker images + run: docker save ${{ env.DOCKER_ORG }}/admin ${{ env.DOCKER_ORG }}/clamav ${{ env.DOCKER_ORG }}/docs ${{ env.DOCKER_ORG }}/dovecot ${{ env.DOCKER_ORG }}/fetchmail ${{ env.DOCKER_ORG }}/nginx ${{ env.DOCKER_ORG }}/none ${{ env.DOCKER_ORG }}/postfix ${{ env.DOCKER_ORG }}/postgresql ${{ env.DOCKER_ORG }}/radicale ${{ env.DOCKER_ORG }}/rainloop ${{ env.DOCKER_ORG }}/roundcube ${{ env.DOCKER_ORG }}/rspamd ${{ env.DOCKER_ORG }}/setup ${{ env.DOCKER_ORG }}/traefik-certdumper ${{ env.DOCKER_ORG }}/unbound -o /images/images.tar.gz + + test-core: + name: Perform core tests + runs-on: ubuntu-latest + needs: + - build + steps: + - uses: actions/checkout@v2 + - name: Extract branch name + shell: bash + run: | + echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for branch testing + if: ${{ env.BRANCH == 'testing' }} + shell: bash + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + echo "MAILU_VERSION=pr-${COMMIT_MESSAGE//[!0-9]/}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG_TESTS }}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for other branches than testing + if: ${{ env.BRANCH != 'testing' }} + shell: bash + env: + MAILU_BRANCH: ${{ env.BRANCH }} + run: | + echo "MAILU_VERSION=${{ env.MAILU_BRANCH }}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG }}" >> $GITHUB_ENV + - name: Create folder for storing images + run: | + sudo mkdir -p /images + sudo chmod 777 /images + - name: Configure images folder for caching + uses: actions/cache@v2 + with: + path: /images + key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }} + - name: Load docker images + run: docker load -i /images/images.tar.gz + - name: Install python packages + run: python3 -m pip install -r tests/requirements.txt + - name: Copy all certs + run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*' + - name: Test core suite + run: python tests/compose/test.py core 2 + env: + MAILU_VERSION: ${{ env.MAILU_VERSION }} + TRAVIS_BRANCH: ${{ env.BRANCH }} + DOCKER_ORG: ${{ env.DOCKER_ORG }} + + test-fetchmail: + name: Perform fetchmail tests + runs-on: ubuntu-latest + needs: + - build + steps: + - uses: actions/checkout@v2 + - name: Extract branch name + shell: bash + run: | + echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for branch testing + if: ${{ env.BRANCH == 'testing' }} + shell: bash + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + echo "MAILU_VERSION=pr-${COMMIT_MESSAGE//[!0-9]/}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG_TESTS }}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for other branches than testing + if: ${{ env.BRANCH != 'testing' }} + shell: bash + env: + MAILU_BRANCH: ${{ env.BRANCH }} + run: | + echo "MAILU_VERSION=${{ env.MAILU_BRANCH }}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG }}" >> $GITHUB_ENV + - name: Create folder for storing images + run: | + sudo mkdir -p /images + sudo chmod 777 /images + - name: Configure images folder for caching + uses: actions/cache@v2 + with: + path: /images + key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }} + - name: Load docker images + run: docker load -i /images/images.tar.gz + - name: Install python packages + run: python3 -m pip install -r tests/requirements.txt + - name: Copy all certs + run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*' + - name: Test fetch + run: python tests/compose/test.py fetchmail 2 + env: + MAILU_VERSION: ${{ env.MAILU_VERSION }} + TRAVIS_BRANCH: ${{ env.BRANCH }} + DOCKER_ORG: ${{ env.DOCKER_ORG }} + + test-filters: + name: Perform filter tests + runs-on: ubuntu-latest + needs: + - build + steps: + - uses: actions/checkout@v2 + - name: Extract branch name + shell: bash + run: | + echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for branch testing + if: ${{ env.BRANCH == 'testing' }} + shell: bash + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + echo "MAILU_VERSION=pr-${COMMIT_MESSAGE//[!0-9]/}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG_TESTS }}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for other branches than testing + if: ${{ env.BRANCH != 'testing' }} + shell: bash + env: + MAILU_BRANCH: ${{ env.BRANCH }} + run: | + echo "MAILU_VERSION=${{ env.MAILU_BRANCH }}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG }}" >> $GITHUB_ENV + - name: Create folder for storing images + run: | + sudo mkdir -p /images + sudo chmod 777 /images + - name: Configure images folder for caching + uses: actions/cache@v2 + with: + path: /images + key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }} + - name: Load docker images + run: docker load -i /images/images.tar.gz + - name: Install python packages + run: python3 -m pip install -r tests/requirements.txt + - name: Copy all certs + run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*' + - name: Test clamvav + run: python tests/compose/test.py filters 3 + env: + MAILU_VERSION: ${{ env.MAILU_VERSION }} + TRAVIS_BRANCH: ${{ env.BRANCH }} + DOCKER_ORG: ${{ env.DOCKER_ORG }} + + test-rainloop: + name: Perform rainloop tests + runs-on: ubuntu-latest + needs: + - build + steps: + - uses: actions/checkout@v2 + - name: Extract branch name + shell: bash + run: | + echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for branch testing + if: ${{ env.BRANCH == 'testing' }} + shell: bash + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + echo "MAILU_VERSION=pr-${COMMIT_MESSAGE//[!0-9]/}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG_TESTS }}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for other branches than testing + if: ${{ env.BRANCH != 'testing' }} + shell: bash + env: + MAILU_BRANCH: ${{ env.BRANCH }} + run: | + echo "MAILU_VERSION=${{ env.MAILU_BRANCH }}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG }}" >> $GITHUB_ENV + - name: Create folder for storing images + run: | + sudo mkdir -p /images + sudo chmod 777 /images + - name: Configure images folder for caching + uses: actions/cache@v2 + with: + path: /images + key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }} + - name: Load docker images + run: docker load -i /images/images.tar.gz + - name: Install python packages + run: python3 -m pip install -r tests/requirements.txt + - name: Copy all certs + run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*' + - name: Test rainloop + run: python tests/compose/test.py rainloop 2 + env: + MAILU_VERSION: ${{ env.MAILU_VERSION }} + TRAVIS_BRANCH: ${{ env.BRANCH }} + DOCKER_ORG: ${{ env.DOCKER_ORG }} + + test-roundcube: + name: Perform roundcube tests + runs-on: ubuntu-latest + needs: + - build + steps: + - uses: actions/checkout@v2 + - name: Extract branch name + shell: bash + run: | + echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for branch testing + if: ${{ env.BRANCH == 'testing' }} + shell: bash + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + echo "MAILU_VERSION=pr-${COMMIT_MESSAGE//[!0-9]/}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG_TESTS }}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for other branches than testing + if: ${{ env.BRANCH != 'testing' }} + shell: bash + env: + MAILU_BRANCH: ${{ env.BRANCH }} + run: | + echo "MAILU_VERSION=${{ env.MAILU_BRANCH }}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG }}" >> $GITHUB_ENV + - name: Create folder for storing images + run: | + sudo mkdir -p /images + sudo chmod 777 /images + - name: Configure images folder for caching + uses: actions/cache@v2 + with: + path: /images + key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }} + - name: Load docker images + run: docker load -i /images/images.tar.gz + - name: Install python packages + run: python3 -m pip install -r tests/requirements.txt + - name: Copy all certs + run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*' + - name: Test roundcube + run: python tests/compose/test.py roundcube 2 + env: + MAILU_VERSION: ${{ env.MAILU_VERSION }} + TRAVIS_BRANCH: ${{ env.BRANCH }} + DOCKER_ORG: ${{ env.DOCKER_ORG }} + + test-webdav: + name: Perform webdav tests + runs-on: ubuntu-latest + needs: + - build + steps: + - uses: actions/checkout@v2 + - name: Extract branch name + shell: bash + run: | + echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for branch testing + if: ${{ env.BRANCH == 'testing' }} + shell: bash + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + echo "MAILU_VERSION=pr-${COMMIT_MESSAGE//[!0-9]/}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG_TESTS }}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for other branches than testing + if: ${{ env.BRANCH != 'testing' }} + shell: bash + env: + MAILU_BRANCH: ${{ env.BRANCH }} + run: | + echo "MAILU_VERSION=${{ env.MAILU_BRANCH }}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG }}" >> $GITHUB_ENV + - name: Create folder for storing images + run: | + sudo mkdir -p /images + sudo chmod 777 /images + - name: Configure images folder for caching + uses: actions/cache@v2 + with: + path: /images + key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }} + - name: Load docker images + run: docker load -i /images/images.tar.gz + - name: Install python packages + run: python3 -m pip install -r tests/requirements.txt + - name: Copy all certs + run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*' + - name: Test webdav + run: python tests/compose/test.py webdav 2 + env: + MAILU_VERSION: ${{ env.MAILU_VERSION }} + TRAVIS_BRANCH: ${{ env.BRANCH }} + DOCKER_ORG: ${{ env.DOCKER_ORG }} + + deploy: + name: Deploy images + runs-on: ubuntu-latest + needs: + - build + - test-core + - test-fetchmail + - test-filters + - test-rainloop + - test-roundcube + - test-webdav + steps: + - uses: actions/checkout@v2 + - name: Extract branch name + shell: bash + run: | + echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + #For branch TESTING, we set the image tag to PR-xxxx + - name: Derive MAILU_VERSION for branch testing + if: ${{ env.BRANCH == 'testing' }} + shell: bash + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + echo "MAILU_VERSION=pr-${COMMIT_MESSAGE//[!0-9]/}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG_TESTS }}" >> $GITHUB_ENV + - name: Derive MAILU_VERSION for other branches than testing + if: ${{ env.BRANCH != 'testing' }} + shell: bash + env: + MAILU_BRANCH: ${{ env.BRANCH }} + run: | + echo "MAILU_VERSION=${{ env.MAILU_BRANCH }}" >> $GITHUB_ENV + echo "DOCKER_ORG=${{ secrets.DOCKER_ORG }}" >> $GITHUB_ENV + - name: Create folder for storing images + run: | + sudo mkdir -p /images + sudo chmod 777 /images + - name: Configure images folder for caching + # For staging we do not deploy images. So we do not have to load them from cache. + if: ${{ env.BRANCH != 'staging' }} + uses: actions/cache@v2 + with: + path: /images + key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }} + - name: Load docker images + if: ${{ env.BRANCH != 'staging' }} + run: docker load -i /images/images.tar.gz + - name: Deploy built docker images + env: + DOCKER_UN: ${{ secrets.Docker_Login }} + DOCKER_PW: ${{ secrets.Docker_Password }} + DOCKER_ORG: ${{ env.DOCKER_ORG }} + MAILU_VERSION: ${{ env.MAILU_VERSION }} + TRAVIS_BRANCH: ${{ env.BRANCH }} + run: bash tests/deploy.sh + + # This job is watched by bors. It only complets if building,testing and deploy worked. + ci-success: + name: CI-Done + #Returns true when none of the **previous** steps have failed or been canceled. + if: ${{ success() }} + needs: + - deploy + runs-on: ubuntu-latest + steps: + - name: CI/CD succeeded. + run: exit 0 diff --git a/.mergify.yml b/.mergify.yml index c1141a93..02e41922 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -27,7 +27,7 @@ pull_request_rules: - name: Trusted author and 1 approved review; trigger bors r+ conditions: - - author~=^(kaiyou|muhlemmer|mildred|HorayNarea|adi90x|hoellen|ofthesun9|Nebukadneza|micw|lub|Diman0)$ + - author~=^(mergify|kaiyou|muhlemmer|mildred|HorayNarea|hoellen|ofthesun9|Nebukadneza|micw|lub|Diman0|ghostwheel42|nextgens)$ - -title~=(WIP|wip) - -label~=^(status/wip|status/blocked|review/need2)$ - "#approved-reviews-by>=1" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 467f6f5b..00000000 --- a/.travis.yml +++ /dev/null @@ -1,55 +0,0 @@ -branches: - only: - - staging - - testing - - '1.5' - - '1.6' - - '1.7' - - '1.8' - - master - # version tags, e.g. 1.7.1 - - /^1\.[5678]\.\d+$/ - # pre-releases, e.g. 1.8-pre1 - - /^1\.8-pre\d+$/ - # test branches, e.g. test-debian - - /^test-[\w\-\.]+$/ - -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c78f33..da945c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,49 @@ Changelog Upgrade should run fine as long as you generate a new compose or stack configuration and upgrade your mailu.env. -Please note that the current 1.8 is what we call a "soft release": It’s there for everyone to see and use, but to limit possible user-impact of this very big release, it’s not yet the default in the setup-utility for new users. When upgrading, please treat it with some care, and be sure to always have backups! - There are some changes to the configuration overrides. Override files are now mounted read-only into the containers. The Dovecot and Postfix overrides are moved in their own sub-directory. If there are local override files, they will need to be moved from overrides/ to overrides/dovecot and overrides/postfix/. See https://mailu.io/1.8/faq.html#how-can-i-override-settings for all the mappings. -Please not that the shipped image for PostgreSQL database is deprecated. -We advise to switch to an external database server. +One major change for the docker compose file is that the antispam container needs a fixed hostname [#1837](https://github.com/Mailu/Mailu/issues/1837). +This is handled when you regenerate the docker-compose file. A fixed hostname is required to retain rspamd history. +This is also handled in the helm-chart repo. - -v1.8.0 - 2020-09-28 +Improvements have been made to protect again session-fixation attacks. +To be fully protected, it is required to change your SECRET_KEY in Mailu.env after upgrading. +A new SECRET_KEY is generated when you recreate your docker-compose.yml & mailu.env file via setup.mailu.io. + +The SECRET_KEY is an uppercase alphanumeric string of length 16. You can manually create such a string via +```cat /dev/urandom | tr -dc 'A-Z0-9' | fold -w ${1:-16} | head -n 1``` + +After changing mailu.env, it is required to recreate all containers for the changes to be propagated. + +Please note that the shipped image for PostgreSQL database is deprecated. +We advise to switch to an external PostgreSQL database server. + + +1.8.0 - 2021-08-06 +-------------------- + +- Features: Update version of roundcube webmail and carddav plugin. This is a security update. ([#1841](https://github.com/Mailu/Mailu/issues/1841)) +- Features: Update version of rainloop webmail to 1.16.0. This is a security update. ([#1845](https://github.com/Mailu/Mailu/issues/1845)) +- Features: Changed default value of AUTH_RATELIMIT_SUBNET to false. Increased default value of the rate limit in setup utility (AUTH_RATELIMIT) to a higher value. ([#1867](https://github.com/Mailu/Mailu/issues/1867)) +- Features: Update jquery used in setup. Set pinned versions in requirements.txt for setup. This is a security update. ([#1880](https://github.com/Mailu/Mailu/issues/1880)) +- Bugfixes: Replace PUBLIC_HOSTNAME and PUBLIC_IP in "Received" headers to ensure that no undue spam points are attributed ([#191](https://github.com/Mailu/Mailu/issues/191)) +- Bugfixes: Don't replace nested headers (typically in attached emails) ([#1660](https://github.com/Mailu/Mailu/issues/1660)) +- Bugfixes: Fix letsencrypt access to certbot for the mail-letsencrypt flavour ([#1686](https://github.com/Mailu/Mailu/issues/1686)) +- Bugfixes: Fix CVE-2020-25275 and CVE-2020-24386 by upgrading alpine for + dovecot which contains a fixed dovecot version. ([#1720](https://github.com/Mailu/Mailu/issues/1720)) +- Bugfixes: Antispam service now uses a static hostname. Rspamd history is only retained when the service has a fixed hostname. ([#1837](https://github.com/Mailu/Mailu/issues/1837)) +- Bugfixes: Fix a bug preventing colons from being used in passwords when using radicale/webdav. ([#1861](https://github.com/Mailu/Mailu/issues/1861)) +- Bugfixes: Remove dot in blueprint name to prevent critical flask startup error in setup. ([#1874](https://github.com/Mailu/Mailu/issues/1874)) +- Bugfixes: fix punycode encoding of domain names ([#1891](https://github.com/Mailu/Mailu/issues/1891)) +- Improved Documentation: Update fail2ban documentation to use systemd backend instead of filepath for journald ([#1857](https://github.com/Mailu/Mailu/issues/1857)) +- Misc: Switch from client side (cookie) sessions to server side sessions and protect against session-fixation attacks. We recommend that you change your SECRET_KEY after upgrading. ([#1783](https://github.com/Mailu/Mailu/issues/1783)) + + +v1.8.0rc - 2020-09-28 -------------------- - Features: Add support for backward-forwarding using SRS ([#328](https://github.com/Mailu/Mailu/issues/328)) @@ -66,7 +97,7 @@ configuration and upgrade your mailu.env. If you run the PostgreSQL server, the database was upgrade, so you will need to dump the database before upgrading and load the dump after the upgrade is -complete. Please not that the shipped image for PostgreSQL database will be +complete. Please note that the shipped image for PostgreSQL database will be deprecated before 1.8.0, you can switch to an external database server by then. - Deprecation: using the internal postgres image will be deprecated by 1.8.0 diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index 059318fc..fc3b6b61 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -8,9 +8,9 @@ - Mention an issue like: #001 - Auto close an issue like: closes #001 -## Prerequistes +## Prerequisites Before we can consider review and merge, please make sure the following list is done and checked. If an entry in not applicable, you can check it or remove it from the list. - [ ] In case of feature or enhancement: documentation updated accordingly -- [ ] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/guide.html#changelog) entry file. +- [ ] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/workflow.html#changelog) entry file. diff --git a/README.md b/README.md index c4354b28..4c19ad78 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Main features include: - **Web access**, multiple Webmails and administration interface - **User features**, aliases, auto-reply, auto-forward, fetched accounts - **Admin features**, global admins, announcements, per-domain delegation, quotas -- **Security**, enforced TLS, Letsencrypt!, outgoing DKIM, anti-virus scanner +- **Security**, enforced TLS, DANE, MTA-STS, Letsencrypt!, outgoing DKIM, anti-virus scanner - **Antispam**, auto-learn, greylisting, DMARC and SPF - **Freedom**, all FOSS components, no tracker included diff --git a/bors.toml b/bors.toml index 5279fe72..272a6047 100644 --- a/bors.toml +++ b/bors.toml @@ -1,3 +1,4 @@ status = [ - "continuous-integration/travis-ci/push" + "CI-Done" ] + diff --git a/core/admin/Dockerfile b/core/admin/Dockerfile index f3b8643c..1958ae61 100644 --- a/core/admin/Dockerfile +++ b/core/admin/Dockerfile @@ -1,39 +1,51 @@ # First stage to build assets -ARG DISTRO=alpine:3.12 +ARG DISTRO=alpine:3.14.2 ARG ARCH="" -FROM ${ARCH}node:8 as assets -COPY --from=balenalib/rpi-alpine:3.10 /usr/bin/qemu-arm-static /usr/bin/qemu-arm-static + +FROM ${ARCH}node:16 as assets COPY package.json ./ -RUN npm install +RUN set -eu \ + && npm config set update-notifier false \ + && npm install --no-fund -COPY ./webpack.config.js ./ -COPY ./assets ./assets -RUN mkdir static \ - && ./node_modules/.bin/webpack-cli +COPY webpack.config.js ./ +COPY assets ./assets +RUN set -eu \ + && sed -i 's/#007bff/#55a5d9/' node_modules/admin-lte/build/scss/_bootstrap-variables.scss \ + && for l in ca da de:de_de en:en-gb es:es_es eu fr:fr_fr he hu is it:it_it ja nb_NO:no_nb nl:nl_nl pl pt:pt_pt ru sv:sv_se zh; do \ + cp node_modules/datatables.net-plugins/i18n/${l#*:}.json assets/${l%:*}.json; \ + done \ + && node_modules/.bin/webpack-cli --color # Actual application FROM $DISTRO +COPY --from=balenalib/rpi-alpine:3.14 /usr/bin/qemu-arm-static /usr/bin/qemu-arm-static + +ENV TZ Etc/UTC + # python3 shared with most images -RUN apk add --no-cache \ - python3 py3-pip git bash \ - && pip3 install --upgrade pip +RUN set -eu \ + && apk add --no-cache python3 py3-pip py3-wheel git bash tzdata \ + && pip3 install --upgrade pip RUN mkdir -p /app WORKDIR /app COPY requirements-prod.txt requirements.txt -RUN apk add --no-cache libressl curl postgresql-libs mariadb-connector-c \ - && apk add --no-cache --virtual build-dep \ - libressl-dev libffi-dev python3-dev build-base postgresql-dev mariadb-connector-c-dev \ - && pip3 install -r requirements.txt \ - && apk del --no-cache build-dep +RUN set -eu \ + && apk add --no-cache libressl curl postgresql-libs mariadb-connector-c \ + && apk add --no-cache --virtual build-dep libressl-dev libffi-dev python3-dev build-base postgresql-dev mariadb-connector-c-dev cargo \ + && pip install --upgrade pip \ + && pip install -r requirements.txt \ + && apk del --no-cache build-dep -COPY --from=assets static ./mailu/ui/static +COPY --from=assets static ./mailu/static COPY mailu ./mailu COPY migrations ./migrations COPY start.py /start.py +COPY audit.py /audit.py RUN pybabel compile -d mailu/translations @@ -43,4 +55,4 @@ ENV FLASK_APP mailu CMD /start.py -HEALTHCHECK CMD curl -f -L http://localhost/ui/login?next=ui.index || exit 1 +HEALTHCHECK CMD curl -f -L http://localhost/sso/login?next=ui.index || exit 1 diff --git a/core/admin/assets/app.css b/core/admin/assets/app.css index e1656429..84644900 100644 --- a/core/admin/assets/app.css +++ b/core/admin/assets/app.css @@ -1,22 +1,59 @@ -.select2-search--inline .select2-search__field:focus { - border: none; +/* mailu logo */ +.mailu-logo { + opacity: .8; +} +.bg-mailu-logo { + background-color: #2980b9!important; } -.sidebar h4 { - padding-left: 5px; - padding-right: 5px; - overflow: hidden; - text-overflow: ellipsis; +/* user image */ +.div-circle { + position: relative; + width: 2.1rem; + height: 2.1rem; + opacity: .8; + background-color: white; + border-radius: 50%; +} +.div-circle > i { + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) } -.sidebar-collapse .sidebar h4 { - display: none !important; +/* nice round preformatted configuration display */ +.pre-config { + padding: 9px; + margin: 0; + white-space: pre-wrap; + word-wrap: anywhere; + border-radius: 4px; } -.logo a { - color: #fff; +/* fieldset */ +legend { + font-size: inherit; +} +fieldset:disabled :not(legend) label { + opacity: .5; +} +fieldset:disabled .form-control:disabled { + color: gray; } -.sidebar-toggle { - padding: unset !important; +/* fix animation for icons in menu text */ +.sidebar .nav-link p i { + transition: margin-left .3s linear,opacity .3s ease,visibility .3s ease; +} + +/* fix select2 text color */ +.select2-container--default .select2-selection--multiple .select2-selection__choice { + color: black; +} + +/* range input spacing */ +.input-group-text { + margin-right: 1em; } diff --git a/core/admin/assets/app.js b/core/admin/assets/app.js index dc981081..03ea6215 100644 --- a/core/admin/assets/app.js +++ b/core/admin/assets/app.js @@ -1,10 +1,79 @@ require('./app.css'); -import 'select2'; -jQuery("document").ready(function() { - jQuery(".mailselect").select2({ +import logo from './mailu.png'; +import modules from "./*.json"; + +// TODO: conditionally (or lazy) load select2 and dataTable +$('document').ready(function() { + + // intercept anchors with data-clicked attribute and open alternate location instead + $('[data-clicked]').click(function(e) { + e.preventDefault(); + window.location.href = $(this).data('clicked'); + }); + + // use post for language selection + $('#mailu-languages > a').click(function(e) { + e.preventDefault(); + $.post({ + url: $(this).attr('href'), + success: function() { + window.location = window.location.href; + }, + }); + }); + + // allow en-/disabling of inputs in fieldset with checkbox in legend + $('fieldset legend input[type=checkbox]').change(function() { + var fieldset = $(this).parents('fieldset'); + if (this.checked) { + fieldset.removeAttr('disabled'); + fieldset.find('input,textarea').not(this).removeAttr('disabled'); + } else { + fieldset.attr('disabled', ''); + fieldset.find('input,textarea').not(this).attr('disabled', ''); + } + }); + + // display of range input value + $('input[type=range]').each(function() { + var value_element = $('#'+this.id+'_value'); + if (value_element.length) { + value_element = $(value_element[0]); + var infinity = $(this).data('infinity'); + var step = $(this).attr('step'); + $(this).on('input', function() { + var num = (infinity && this.value == 0) ? '∞' : (this.value/step).toFixed(2); + if (num.endsWith('.00')) num = num.substr(0, num.length - 3); + value_element.text(num); + }).trigger('input'); + } + }); + + // init select2 + $('.mailselect').select2({ tags: true, - tokenSeparators: [',', ' '] - }) + tokenSeparators: [',', ' '], + }); + + // init dataTable + var d = $(document.documentElement); + $('.dataTable').DataTable({ + 'responsive': true, + language: { + url: d.data('static') + d.attr('lang') + '.json', + }, + }); + + // init clipboard.js + new ClipboardJS('.btn-clip'); + + // disable login if not possible + var l = $('#login_needs_https'); + if (l.length && window.location.protocol != 'https:') { + l.removeClass("d-none"); + $('form :input').prop('disabled', true); + } + }); diff --git a/core/admin/assets/mailu.png b/core/admin/assets/mailu.png new file mode 100644 index 00000000..e4f5021f Binary files /dev/null and b/core/admin/assets/mailu.png differ diff --git a/core/admin/assets/vendor.js b/core/admin/assets/vendor.js index f7ed03c8..906448cf 100644 --- a/core/admin/assets/vendor.js +++ b/core/admin/assets/vendor.js @@ -1,19 +1,24 @@ -// jQuery -import jQuery from 'jquery'; -import 'select2/dist/css/select2.css'; - -// bootstrap -import 'bootstrap/less/bootstrap.less'; -import 'bootstrap'; - -// FA -import 'font-awesome/scss/font-awesome.scss'; - // AdminLTE -import 'admin-lte/build/less/AdminLTE-without-plugins.less'; -import 'admin-lte/build/less/select2.less'; -import 'admin-lte/build/less/skins/skin-blue.less'; -import 'admin-lte/build/js/Layout.js'; -import 'admin-lte/build/js/ControlSidebar.js'; -import 'admin-lte/build/js/PushMenu.js'; -import 'admin-lte/build/js/BoxRefresh.js'; +import 'admin-lte/plugins/jquery/jquery.min.js'; +import 'admin-lte/plugins/bootstrap/js/bootstrap.bundle.min.js'; +import 'admin-lte/build/scss/adminlte.scss'; +import 'admin-lte/build/js/AdminLTE.js'; + +// fontawesome plugin +import 'admin-lte/plugins/fontawesome-free/css/all.min.css'; + +// select2 plugin +import 'admin-lte/plugins/select2/css/select2.min.css'; +import 'admin-lte/plugins/select2/js/select2.min.js'; + +// dataTables plugin +import 'admin-lte/plugins/datatables-bs4/css/dataTables.bootstrap4.min.css'; +import 'admin-lte/plugins/datatables-responsive/css/responsive.bootstrap4.min.css'; +import 'admin-lte/plugins/datatables/jquery.dataTables.min.js'; +import 'admin-lte/plugins/datatables-bs4/js/dataTables.bootstrap4.min.js'; +import 'admin-lte/plugins/datatables-responsive/js/dataTables.responsive.min.js'; +import 'admin-lte/plugins/datatables-responsive/js/responsive.bootstrap4.min.js'; + +// clipboard.js +import 'clipboard/dist/clipboard.min.js'; + diff --git a/core/admin/audit.py b/core/admin/audit.py old mode 100644 new mode 100755 index db105ff4..60583f83 --- a/core/admin/audit.py +++ b/core/admin/audit.py @@ -1,14 +1,19 @@ -from mailu import app +#!/usr/bin/python3 import sys import tabulate +sys.path[0:0] = ['/app'] + +import mailu +app = mailu.create_app() + # Known endpoints without permissions known_missing_permissions = [ - "index", - "static", "bootstrap.static", - "admin.static", "admin.login" + 'index', + 'static', 'bootstrap.static', + 'admin.static', 'admin.login' ] @@ -16,7 +21,7 @@ known_missing_permissions = [ missing_permissions = [] permissions = {} for endpoint, function in app.view_functions.items(): - audit = function.__dict__.get("_audit_permissions") + audit = function.__dict__.get('_audit_permissions') if audit: handler, args = audit if args: @@ -28,16 +33,15 @@ for endpoint, function in app.view_functions.items(): elif endpoint not in known_missing_permissions: missing_permissions.append(endpoint) - -# Fail if any endpoint is missing a permission check -if missing_permissions: - print("The following endpoints are missing permission checks:") - print(missing_permissions.join(",")) - sys.exit(1) - - # Display the permissions table print(tabulate.tabulate([ [route, *permissions[route.endpoint]] for route in app.url_map.iter_rules() if route.endpoint in permissions ])) + +# Warn if any endpoint is missing a permission check +if missing_permissions: + print() + print('The following endpoints are missing permission checks:') + print(','.join(missing_permissions)) + diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py index 4de3e580..fe1f376c 100644 --- a/core/admin/mailu/__init__.py +++ b/core/admin/mailu/__init__.py @@ -1,22 +1,26 @@ +""" Mailu admin app +""" + import flask import flask_bootstrap from mailu import utils, debug, models, manage, configuration +import hmac def create_app_from_config(config): """ Create a new application based on the given configuration """ - app = flask.Flask(__name__) + app = flask.Flask(__name__, static_folder='static', static_url_path='/static') app.cli.add_command(manage.mailu) - # Bootstrap is used for basic JS and CSS loading - # TODO: remove this and use statically generated assets instead + # Bootstrap is used for error display and flash messages app.bootstrap = flask_bootstrap.Bootstrap(app) # Initialize application extensions config.init_app(app) models.db.init_app(app) + utils.session.init_app(app) utils.limiter.init_app(app) utils.babel.init_app(app) utils.login.init_app(app) @@ -24,6 +28,19 @@ def create_app_from_config(config): utils.proxy.init_app(app) utils.migrate.init_app(app, models.db) + app.device_cookie_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('DEVICE_COOKIE_KEY', 'utf-8'), 'sha256').digest() + app.temp_token_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('WEBMAIL_TEMP_TOKEN_KEY', 'utf-8'), 'sha256').digest() + app.srs_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('SRS_KEY', 'utf-8'), 'sha256').digest() + + # Initialize list of translations + app.config.translations = { + str(locale): locale + for locale in sorted( + utils.babel.list_translations(), + key=lambda l: l.get_language_name().title() + ) + } + # Initialize debugging tools if app.config.get("DEBUG"): debug.toolbar.init_app(app) @@ -36,20 +53,29 @@ def create_app_from_config(config): def inject_defaults(): signup_domains = models.Domain.query.filter_by(signup_enabled=True).all() return dict( - signup_domains=signup_domains, - config=app.config + signup_domains= signup_domains, + config = app.config, ) - # Import views - from mailu import ui, internal - app.register_blueprint(ui.ui, url_prefix='/ui') - app.register_blueprint(internal.internal, url_prefix='/internal') + # Jinja filters + @app.template_filter() + def format_date(value): + return utils.flask_babel.format_date(value) if value else '' + @app.template_filter() + def format_datetime(value): + return utils.flask_babel.format_datetime(value) if value else '' + + # Import views + from mailu import ui, internal, sso + app.register_blueprint(ui.ui, url_prefix=app.config['WEB_ADMIN']) + app.register_blueprint(internal.internal, url_prefix='/internal') + app.register_blueprint(sso.sso, url_prefix='/sso') return app def create_app(): - """ Create a new application based on the config module + """ Create a new application based on the config module """ config = configuration.ConfigManager() return create_app_from_config(config) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 2cf6a478..b60b8a3e 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -1,6 +1,8 @@ import os +from datetime import timedelta from socrate import system +import ipaddress DEFAULT_CONFIG = { # Specific to the admin UI @@ -13,6 +15,7 @@ DEFAULT_CONFIG = { 'DEBUG': False, 'DOMAIN_REGISTRATION': False, 'TEMPLATES_AUTO_RELOAD': True, + 'MEMORY_SESSIONS': False, # Database settings 'DB_FLAVOR': None, 'DB_USER': 'mailu', @@ -30,9 +33,16 @@ DEFAULT_CONFIG = { 'DOMAIN': 'mailu.io', 'HOSTNAMES': 'mail.mailu.io,alternative.mailu.io,yetanother.mailu.io', 'POSTMASTER': 'postmaster', + 'WILDCARD_SENDERS': '', 'TLS_FLAVOR': 'cert', - 'AUTH_RATELIMIT': '10/minute;1000/hour', - 'AUTH_RATELIMIT_SUBNET': True, + 'INBOUND_TLS_ENFORCE': False, + 'DEFER_ON_TLS_ERROR': True, + 'AUTH_RATELIMIT_IP': '60/hour', + 'AUTH_RATELIMIT_IP_V4_MASK': 24, + 'AUTH_RATELIMIT_IP_V6_MASK': 56, + 'AUTH_RATELIMIT_USER': '100/day', + 'AUTH_RATELIMIT_EXEMPTION': '', + 'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400, 'DISABLE_STATISTICS': False, # Mail settings 'DMARC_RUA': None, @@ -43,17 +53,27 @@ DEFAULT_CONFIG = { 'DKIM_SELECTOR': 'dkim', 'DKIM_PATH': '/dkim/{domain}.{selector}.key', 'DEFAULT_QUOTA': 1000000000, + 'MESSAGE_RATELIMIT': '200/day', + 'MESSAGE_RATELIMIT_EXEMPTION': '', + 'RECIPIENT_DELIMITER': '', # Web settings 'SITENAME': 'Mailu', 'WEBSITE': 'https://mailu.io', + 'ADMIN' : 'none', 'WEB_ADMIN': '/admin', 'WEB_WEBMAIL': '/webmail', 'WEBMAIL': 'none', 'RECAPTCHA_PUBLIC_KEY': '', 'RECAPTCHA_PRIVATE_KEY': '', + 'LOGO_URL': None, + 'LOGO_BACKGROUND': None, # Advanced settings - 'PASSWORD_SCHEME': 'PBKDF2', 'LOG_LEVEL': 'WARNING', + 'SESSION_KEY_BITS': 128, + 'SESSION_LIFETIME': 24, + 'SESSION_COOKIE_SECURE': True, + 'CREDENTIAL_ROUNDS': 12, + 'TZ': 'Etc/UTC', # Host settings 'HOST_IMAP': 'imap', 'HOST_LMTP': 'imap:2525', @@ -61,7 +81,6 @@ DEFAULT_CONFIG = { 'HOST_SMTP': 'smtp', 'HOST_AUTHSMTP': 'smtp', 'HOST_ADMIN': 'admin', - 'WEBMAIL': 'none', 'HOST_WEBMAIL': 'webmail', 'HOST_WEBDAV': 'webdav:5232', 'HOST_REDIS': 'redis', @@ -71,7 +90,7 @@ DEFAULT_CONFIG = { 'POD_ADDRESS_RANGE': None } -class ConfigManager(dict): +class ConfigManager: """ Naive configuration manager that uses environment only """ @@ -86,19 +105,25 @@ class ConfigManager(dict): def get_host_address(self, name): # if MYSERVICE_ADDRESS is defined, use this - if '{}_ADDRESS'.format(name) in os.environ: - return os.environ.get('{}_ADDRESS'.format(name)) + if f'{name}_ADDRESS' in os.environ: + return os.environ.get(f'{name}_ADDRESS') # otherwise use the host name and resolve it - return system.resolve_address(self.config['HOST_{}'.format(name)]) + return system.resolve_address(self.config[f'HOST_{name}']) def resolve_hosts(self): - self.config["IMAP_ADDRESS"] = self.get_host_address("IMAP") - self.config["POP3_ADDRESS"] = self.get_host_address("POP3") - self.config["AUTHSMTP_ADDRESS"] = self.get_host_address("AUTHSMTP") - self.config["SMTP_ADDRESS"] = self.get_host_address("SMTP") - self.config["REDIS_ADDRESS"] = self.get_host_address("REDIS") - if self.config["WEBMAIL"] != "none": - self.config["WEBMAIL_ADDRESS"] = self.get_host_address("WEBMAIL") + for key in ['IMAP', 'POP3', 'AUTHSMTP', 'SMTP', 'REDIS']: + self.config[f'{key}_ADDRESS'] = self.get_host_address(key) + if self.config['WEBMAIL'] != 'none': + self.config['WEBMAIL_ADDRESS'] = self.get_host_address('WEBMAIL') + + def __get_env(self, key, value): + key_file = key + "_FILE" + if key_file in os.environ: + with open(os.environ.get(key_file)) as file: + value_from_file = file.read() + return value_from_file.strip() + else: + return os.environ.get(key, value) def __coerce_value(self, value): if isinstance(value, str) and value.lower() in ('true','yes'): @@ -108,10 +133,11 @@ class ConfigManager(dict): return value def init_app(self, app): + # get current app config self.config.update(app.config) # get environment variables self.config.update({ - key: self.__coerce_value(os.environ.get(key, value)) + key: self.__coerce_value(self.__get_env(key, value)) for key, value in DEFAULT_CONFIG.items() }) self.resolve_hosts() @@ -121,27 +147,18 @@ class ConfigManager(dict): template = self.DB_TEMPLATES[self.config['DB_FLAVOR']] self.config['SQLALCHEMY_DATABASE_URI'] = template.format(**self.config) - self.config['RATELIMIT_STORAGE_URL'] = 'redis://{0}/2'.format(self.config['REDIS_ADDRESS']) - self.config['QUOTA_STORAGE_URL'] = 'redis://{0}/1'.format(self.config['REDIS_ADDRESS']) - # update the app config itself - app.config = self + self.config['RATELIMIT_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/2' + self.config['QUOTA_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/1' + self.config['SESSION_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/3' + self.config['SESSION_COOKIE_SAMESITE'] = 'Strict' + self.config['SESSION_COOKIE_HTTPONLY'] = True + self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) + hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')] + self.config['AUTH_RATELIMIT_EXEMPTION'] = set(ipaddress.ip_network(cidr, False) for cidr in (cidr.strip() for cidr in self.config['AUTH_RATELIMIT_EXEMPTION'].split(',')) if cidr) + self.config['MESSAGE_RATELIMIT_EXEMPTION'] = set([s for s in self.config['MESSAGE_RATELIMIT_EXEMPTION'].lower().replace(' ', '').split(',') if s]) + self.config['HOSTNAMES'] = ','.join(hostnames) + self.config['HOSTNAME'] = hostnames[0] - def setdefault(self, key, value): - if key not in self.config: - self.config[key] = value - return self.config[key] + # update the app config + app.config.update(self.config) - def get(self, *args): - return self.config.get(*args) - - def keys(self): - return self.config.keys() - - def __getitem__(self, key): - return self.config.get(key) - - def __setitem__(self, key, value): - self.config[key] = value - - def __contains__(self, key): - return key in self.config diff --git a/core/admin/mailu/debug.py b/core/admin/mailu/debug.py index 7677901b..4d63f3c5 100644 --- a/core/admin/mailu/debug.py +++ b/core/admin/mailu/debug.py @@ -1,6 +1,6 @@ import flask_debugtoolbar -from werkzeug.contrib import profiler as werkzeug_profiler +from werkzeug.middleware.profiler import ProfilerMiddleware # Debugging toolbar @@ -10,7 +10,7 @@ toolbar = flask_debugtoolbar.DebugToolbarExtension() # Profiler class Profiler(object): def init_app(self, app): - app.wsgi_app = werkzeug_profiler.ProfilerMiddleware( + app.wsgi_app = ProfilerMiddleware( app.wsgi_app, restrictions=[30] ) diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index 1e0b16c2..027db935 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -5,9 +5,9 @@ import re import urllib import ipaddress import socket +import sqlalchemy.exc import tenacity - SUPPORTED_AUTH_METHODS = ["none", "plain"] @@ -17,8 +17,34 @@ STATUSES = { "smtp": "535 5.7.8", "pop3": "-ERR Authentication failed" }), + "encryption": ("Must issue a STARTTLS command first", { + "smtp": "530 5.7.0" + }), + "ratelimit": ("Temporary authentication failure (rate-limit)", { + "imap": "LIMIT", + "smtp": "451 4.3.2", + "pop3": "-ERR [LOGIN-DELAY] Retry later" + }), } +def check_credentials(user, password, ip, protocol=None): + if not user or not user.enabled or (protocol == "imap" and not user.enable_imap) or (protocol == "pop3" and not user.enable_pop): + return False + is_ok = False + # webmails + if len(password) == 64 and ip == app.config['WEBMAIL_ADDRESS']: + if user.verify_temp_token(password): + is_ok = True + # All tokens are 32 characters hex lowercase + if not is_ok and len(password) == 32: + for token in user.tokens: + if (token.check_password(password) and + (not token.ip or token.ip == ip)): + is_ok = True + break + if not is_ok and user.check_password(password): + is_ok = True + return is_ok def handle_authentication(headers): """ Handle an HTTP nginx authentication request @@ -28,51 +54,69 @@ def handle_authentication(headers): protocol = headers["Auth-Protocol"] # Incoming mail, no authentication if method == "none" and protocol == "smtp": - server, port = get_server(headers["Auth-Protocol"], False) - return { - "Auth-Status": "OK", - "Auth-Server": server, - "Auth-Port": port - } - # Authenticated user - elif method == "plain": - server, port = get_server(headers["Auth-Protocol"], True) - # According to RFC2616 section 3.7.1 and PEP 3333, HTTP headers should - # be ASCII and are generally considered ISO8859-1. However when passing - # the password, nginx does not transcode the input UTF string, thus - # we need to manually decode. - raw_user_email = urllib.parse.unquote(headers["Auth-User"]) - user_email = raw_user_email.encode("iso8859-1").decode("utf8") - raw_password = urllib.parse.unquote(headers["Auth-Pass"]) - password = raw_password.encode("iso8859-1").decode("utf8") - ip = urllib.parse.unquote(headers["Client-Ip"]) - user = models.User.query.get(user_email) - status = False - if user: - for token in user.tokens: - if (token.check_password(password) and - (not token.ip or token.ip == ip)): - status = True - if user.check_password(password): - status = True - if status: - if protocol == "imap" and not user.enable_imap: - status = False - elif protocol == "pop3" and not user.enable_pop: - status = False - if status and user.enabled: + server, port = get_server(protocol, False) + if app.config["INBOUND_TLS_ENFORCE"]: + if "Auth-SSL" in headers and headers["Auth-SSL"] == "on": + return { + "Auth-Status": "OK", + "Auth-Server": server, + "Auth-Port": port + } + else: + status, code = get_status(protocol, "encryption") + return { + "Auth-Status": status, + "Auth-Error-Code" : code, + "Auth-Wait": 0 + } + else: return { "Auth-Status": "OK", "Auth-Server": server, "Auth-Port": port } + # Authenticated user + elif method == "plain": + is_valid_user = False + # According to RFC2616 section 3.7.1 and PEP 3333, HTTP headers should + # be ASCII and are generally considered ISO8859-1. However when passing + # the password, nginx does not transcode the input UTF string, thus + # we need to manually decode. + raw_user_email = urllib.parse.unquote(headers["Auth-User"]) + raw_password = urllib.parse.unquote(headers["Auth-Pass"]) + user_email = 'invalid' + try: + user_email = raw_user_email.encode("iso8859-1").decode("utf8") + password = raw_password.encode("iso8859-1").decode("utf8") + ip = urllib.parse.unquote(headers["Client-Ip"]) + except: + app.logger.warn(f'Received undecodable user/password from nginx: {raw_user_email!r}/{raw_password!r}') else: - status, code = get_status(protocol, "authentication") - return { - "Auth-Status": status, - "Auth-Error-Code": code, - "Auth-Wait": 0 - } + try: + user = models.User.query.get(user_email) + is_valid_user = True + except sqlalchemy.exc.StatementError as exc: + exc = str(exc).split('\n', 1)[0] + app.logger.warn(f'Invalid user {user_email!r}: {exc}') + else: + ip = urllib.parse.unquote(headers["Client-Ip"]) + if check_credentials(user, password, ip, protocol): + server, port = get_server(headers["Auth-Protocol"], True) + return { + "Auth-Status": "OK", + "Auth-Server": server, + "Auth-User": user_email, + "Auth-User-Exists": is_valid_user, + "Auth-Port": port + } + status, code = get_status(protocol, "authentication") + return { + "Auth-Status": status, + "Auth-Error-Code": code, + "Auth-User": user_email, + "Auth-User-Exists": is_valid_user, + "Auth-Wait": 0 + } # Unexpected return {} diff --git a/core/admin/mailu/internal/templates/default.sieve b/core/admin/mailu/internal/templates/default.sieve index 5e995611..a29e7aba 100644 --- a/core/admin/mailu/internal/templates/default.sieve +++ b/core/admin/mailu/internal/templates/default.sieve @@ -19,7 +19,7 @@ if header :index 2 :matches "Received" "from * by * for <*>; *" } {% if user.spam_enabled %} -if spamtest :percent :value "gt" :comparator "i;ascii-numeric" "{{ user.spam_threshold }}" +if spamtest :percent :value "gt" :comparator "i;ascii-numeric" "{{ user.spam_threshold }}" { setflag "\\seen"; fileinto :create "Junk"; @@ -32,6 +32,6 @@ if exists "X-Virus" { stop; } -{% if user.reply_active %} +{% if user.reply_active %} vacation :days 1 {% if user.displayed_name != "" %}:from "{{ user.displayed_name }} <{{ user.email }}>"{% endif %} :subject "{{ user.reply_subject }}" "{{ user.reply_body }}"; {% endif %} diff --git a/core/admin/mailu/internal/views/__init__.py b/core/admin/mailu/internal/views/__init__.py index a32106c0..762b2a38 100644 --- a/core/admin/mailu/internal/views/__init__.py +++ b/core/admin/mailu/internal/views/__init__.py @@ -1,3 +1,3 @@ __all__ = [ - 'auth', 'postfix', 'dovecot', 'fetch' + 'auth', 'postfix', 'dovecot', 'fetch', 'rspamd' ] diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 825dba56..344be78b 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -5,19 +5,24 @@ from flask import current_app as app import flask import flask_login import base64 -import ipaddress - @internal.route("/auth/email") def nginx_authentication(): """ Main authentication endpoint for Nginx email server """ - limiter = utils.limiter.get_limiter(app.config["AUTH_RATELIMIT"], "auth-ip") client_ip = flask.request.headers["Client-Ip"] - if not limiter.test(client_ip): + headers = flask.request.headers + if headers["Auth-Port"] == '25' and headers['Auth-Method'] == 'plain': response = flask.Response() - response.headers['Auth-Status'] = 'Authentication rate limit from one source exceeded' - response.headers['Auth-Error-Code'] = '451 4.3.2' + response.headers['Auth-Status'] = 'AUTH not supported' + response.headers['Auth-Error-Code'] = '502 5.5.1' + utils.limiter.rate_limit_ip(client_ip) + return response + if utils.limiter.should_rate_limit_ip(client_ip): + status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit') + response = flask.Response() + response.headers['Auth-Status'] = status + response.headers['Auth-Error-Code'] = code if int(flask.request.headers['Auth-Login-Attempt']) < 10: response.headers['Auth-Wait'] = '3' return response @@ -25,14 +30,27 @@ def nginx_authentication(): response = flask.Response() for key, value in headers.items(): response.headers[key] = str(value) - if ("Auth-Status" not in headers) or (headers["Auth-Status"] != "OK"): - limit_subnet = str(app.config["AUTH_RATELIMIT_SUBNET"]) != 'False' - subnet = ipaddress.ip_network(app.config["SUBNET"]) - if limit_subnet or ipaddress.ip_address(client_ip) not in subnet: - limiter.hit(flask.request.headers["Client-Ip"]) + is_valid_user = False + if response.headers.get("Auth-User-Exists"): + username = response.headers["Auth-User"] + if utils.limiter.should_rate_limit_user(username, client_ip): + # FIXME could be done before handle_authentication() + status, code = nginx.get_status(flask.request.headers['Auth-Protocol'], 'ratelimit') + response = flask.Response() + response.headers['Auth-Status'] = status + response.headers['Auth-Error-Code'] = code + if int(flask.request.headers['Auth-Login-Attempt']) < 10: + response.headers['Auth-Wait'] = '3' + return response + is_valid_user = True + if headers.get("Auth-Status") == "OK": + utils.limiter.exempt_ip_from_ratelimits(client_ip) + elif is_valid_user: + utils.limiter.rate_limit_user(username, client_ip) + else: + utils.limiter.rate_limit_ip(client_ip) return response - @internal.route("/auth/admin") def admin_authentication(): """ Fails if the user is not an authenticated admin. @@ -43,20 +61,46 @@ def admin_authentication(): return "" return flask.abort(403) +@internal.route("/auth/user") +def user_authentication(): + """ Fails if the user is not authenticated. + """ + if (not flask_login.current_user.is_anonymous + and flask_login.current_user.enabled): + response = flask.Response() + response.headers["X-User"] = models.IdnaEmail.process_bind_param(flask_login, flask_login.current_user.get_id(), "") + response.headers["X-User-Token"] = models.User.get_temp_token(flask_login.current_user.get_id()) + return response + return flask.abort(403) + @internal.route("/auth/basic") def basic_authentication(): """ Tries to authenticate using the Authorization header. """ + client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) + if utils.limiter.should_rate_limit_ip(client_ip): + response = flask.Response(status=401) + response.headers["WWW-Authenticate"] = 'Basic realm="Authentication rate limit from one source exceeded"' + response.headers['Retry-After'] = '60' + return response authorization = flask.request.headers.get("Authorization") if authorization and authorization.startswith("Basic "): encoded = authorization.replace("Basic ", "") - user_email, password = base64.b64decode(encoded).split(b":") - user = models.User.query.get(user_email.decode("utf8")) - if user and user.enabled and user.check_password(password.decode("utf8")): - response = flask.Response() - response.headers["X-User"] = user.email + user_email, password = base64.b64decode(encoded).split(b":", 1) + user_email = user_email.decode("utf8") + if utils.limiter.should_rate_limit_user(user_email, client_ip): + response = flask.Response(status=401) + response.headers["WWW-Authenticate"] = 'Basic realm="Authentication rate limit for this username exceeded"' + response.headers['Retry-After'] = '60' return response + user = models.User.query.get(user_email) + if user and nginx.check_credentials(user, password.decode('utf-8'), client_ip, "web"): + response = flask.Response() + response.headers["X-User"] = models.IdnaEmail.process_bind_param(flask_login, user.email, "") + utils.limiter.exempt_ip_from_ratelimits(client_ip) + return response + utils.limiter.rate_limit_user(user_email, client_ip) if user else utils.limiter.rate_limit_ip(client_ip) response = flask.Response(status=401) response.headers["WWW-Authenticate"] = 'Basic realm="Login Required"' return response diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py index a5507830..ed951943 100644 --- a/core/admin/mailu/internal/views/postfix.py +++ b/core/admin/mailu/internal/views/postfix.py @@ -1,10 +1,15 @@ -from mailu import models +from mailu import models, utils from mailu.internal import internal +from flask import current_app as app import flask +import idna import re import srslib +@internal.route("/postfix/dane/") +def postfix_dane_map(domain_name): + return flask.jsonify('dane-only') if utils.has_dane_record(domain_name) else flask.abort(404) @internal.route("/postfix/domain/") def postfix_mailbox_domain(domain_name): @@ -30,18 +35,71 @@ def postfix_alias_map(alias): destination = models.Email.resolve_destination(localpart, domain_name) return flask.jsonify(",".join(destination)) if destination else flask.abort(404) - @internal.route("/postfix/transport/") def postfix_transport(email): if email == '*' or re.match("(^|.*@)\[.*\]$", email): return flask.abort(404) - localpart, domain_name = models.Email.resolve_domain(email) + _, domain_name = models.Email.resolve_domain(email) relay = models.Relay.query.get(domain_name) or flask.abort(404) - ret = "smtp:[{0}]".format(relay.smtp) - if ":" in relay.smtp: - split = relay.smtp.split(':') - ret = "smtp:[{0}]:{1}".format(split[0], split[1]) - return flask.jsonify(ret) + target = relay.smtp.lower() + port = None + use_lmtp = False + use_mx = False + # strip prefixes mx: and lmtp: + if target.startswith('mx:'): + target = target[3:] + use_mx = True + elif target.startswith('lmtp:'): + target = target[5:] + use_lmtp = True + # split host:port or [host]:port + if target.startswith('['): + if use_mx or ']' not in target: + # invalid target (mx: and [] or missing ]) + flask.abort(400) + host, rest = target[1:].split(']', 1) + if rest.startswith(':'): + port = rest[1:] + elif rest: + # invalid target (rest should be :port) + flask.abort(400) + else: + if ':' in target: + host, port = target.rsplit(':', 1) + else: + host = target + # default for empty host part is mx:domain + if not host: + if not use_lmtp: + host = relay.name.lower() + use_mx = True + else: + # lmtp: needs a host part + flask.abort(400) + # detect ipv6 address or encode host + if ':' in host: + host = f'ipv6:{host}' + else: + try: + host = idna.encode(host).decode('ascii') + except idna.IDNAError: + # invalid host (fqdn not encodable) + flask.abort(400) + # validate port + if port is not None: + try: + port = int(port, 10) + except ValueError: + # invalid port (should be numeric) + flask.abort(400) + # create transport + transport = 'lmtp' if use_lmtp else 'smtp' + # use [] when not using MX lookups or host is an ipv6 address + if host.startswith('ipv6:') or (not use_lmtp and not use_mx): + host = f'[{host}]' + # create port suffix + port = '' if port is None else f':{port}' + return flask.jsonify(f'{transport}:{host}{port}') @internal.route("/postfix/recipient/map/") @@ -50,7 +108,7 @@ def postfix_recipient_map(recipient): This is meant for bounces to go back to the original sender. """ - srs = srslib.SRS(flask.current_app.config["SECRET_KEY"]) + srs = srslib.SRS(flask.current_app.srs_key) if srslib.SRS.is_srs_address(recipient): try: return flask.jsonify(srs.reverse(recipient)) @@ -65,7 +123,7 @@ def postfix_sender_map(sender): This is for bounces to come back the reverse path properly. """ - srs = srslib.SRS(flask.current_app.config["SECRET_KEY"]) + srs = srslib.SRS(flask.current_app.srs_key) domain = flask.current_app.config["DOMAIN"] try: localpart, domain_name = models.Email.resolve_domain(sender) @@ -78,12 +136,23 @@ def postfix_sender_map(sender): @internal.route("/postfix/sender/login/") def postfix_sender_login(sender): + wildcard_senders = [s for s in flask.current_app.config.get('WILDCARD_SENDERS', '').lower().replace(' ', '').split(',') if s] localpart, domain_name = models.Email.resolve_domain(sender) if localpart is None: - return flask.abort(404) + return flask.jsonify(",".join(wildcard_senders)) if wildcard_senders else flask.abort(404) + localpart = localpart[:next((i for i, ch in enumerate(localpart) if ch in flask.current_app.config.get('RECIPIENT_DELIMITER')), None)] destination = models.Email.resolve_destination(localpart, domain_name, True) + destination = [*destination, *wildcard_senders] if destination else [*wildcard_senders] return flask.jsonify(",".join(destination)) if destination else flask.abort(404) +@internal.route("/postfix/sender/rate/") +def postfix_sender_rate(sender): + """ Rate limit outbound emails per sender login + """ + if sender in flask.current_app.config['MESSAGE_RATELIMIT_EXEMPTION']: + flask.abort(404) + user = models.User.get(sender) or flask.abort(404) + return flask.abort(404) if user.sender_limiter.hit() else flask.jsonify("450 4.2.1 You are sending too many emails too fast.") @internal.route("/postfix/sender/access/") def postfix_sender_access(sender): diff --git a/core/admin/mailu/internal/views/rspamd.py b/core/admin/mailu/internal/views/rspamd.py new file mode 100644 index 00000000..458dbb81 --- /dev/null +++ b/core/admin/mailu/internal/views/rspamd.py @@ -0,0 +1,27 @@ +from mailu import models +from mailu.internal import internal + +import flask + +def vault_error(*messages, status=404): + return flask.make_response(flask.jsonify({'errors':messages}), status) + +# rspamd key format: +# {"selectors":[{"pubkey":"...","domain":"...","valid_start":TS,"valid_end":TS,"key":"...","selector":"...","bits":...,"alg":"..."}]} + +# hashicorp vault answer format: +# {"request_id":"...","lease_id":"","renewable":false,"lease_duration":2764800,"data":{...see above...},"wrap_info":null,"warnings":null,"auth":null} + +@internal.route("/rspamd/vault/v1/dkim/", methods=['GET']) +def rspamd_dkim_key(domain_name): + selectors = [] + if domain := models.Domain.query.get(domain_name): + if key := domain.dkim_key: + selectors.append( + { + 'domain' : domain.name, + 'key' : key.decode('utf8'), + 'selector': flask.current_app.config.get('DKIM_SELECTOR', 'dkim'), + } + ) + return flask.jsonify({'data': {'selectors': selectors}}) diff --git a/core/admin/mailu/limiter.py b/core/admin/mailu/limiter.py index b5f99915..3bc65f4f 100644 --- a/core/admin/mailu/limiter.py +++ b/core/admin/mailu/limiter.py @@ -1,7 +1,12 @@ +from mailu import utils +from flask import current_app as app +import base64 import limits import limits.storage import limits.strategies +import hmac +import secrets class LimitWrapper(object): """ Wraps a limit by providing the storage, item and identifiers @@ -31,4 +36,59 @@ class LimitWraperFactory(object): self.limiter = limits.strategies.MovingWindowRateLimiter(self.storage) def get_limiter(self, limit, *args): - return LimitWrapper(self.limiter, limits.parse(limit), *args) \ No newline at end of file + return LimitWrapper(self.limiter, limits.parse(limit), *args) + + def is_subject_to_rate_limits(self, ip): + return False if utils.is_exempt_from_ratelimits(ip) else not (self.storage.get(f'exempt-{ip}') > 0) + + def exempt_ip_from_ratelimits(self, ip): + self.storage.incr(f'exempt-{ip}', app.config["AUTH_RATELIMIT_EXEMPTION_LENGTH"], True) + + def should_rate_limit_ip(self, ip): + limiter = self.get_limiter(app.config["AUTH_RATELIMIT_IP"], 'auth-ip') + client_network = utils.extract_network_from_ip(ip) + is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(client_network) + if is_rate_limited: + app.logger.warn(f'Authentication attempt from {ip} has been rate-limited.') + return is_rate_limited + + def rate_limit_ip(self, ip): + if ip != app.config['WEBMAIL_ADDRESS']: + limiter = self.get_limiter(app.config["AUTH_RATELIMIT_IP"], 'auth-ip') + client_network = utils.extract_network_from_ip(ip) + if self.is_subject_to_rate_limits(ip): + limiter.hit(client_network) + + def should_rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None): + limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') + is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(device_cookie if device_cookie_name == username else username) + if is_rate_limited: + app.logger.warn(f'Authentication attempt from {ip} for {username} has been rate-limited.') + return is_rate_limited + + def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None): + limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') + if self.is_subject_to_rate_limits(ip): + limiter.hit(device_cookie if device_cookie_name == username else username) + + """ Device cookies as described on: + https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies + """ + def parse_device_cookie(self, cookie): + try: + login, nonce, _ = cookie.split('$') + if hmac.compare_digest(cookie, self.device_cookie(login, nonce)): + return nonce, login + except: + pass + return None, None + + """ Device cookies don't require strong crypto: + 72bits of nonce, 96bits of signature is more than enough + and these values avoid padding in most cases + """ + def device_cookie(self, username, nonce=None): + if not nonce: + nonce = secrets.token_urlsafe(9) + sig = str(base64.urlsafe_b64encode(hmac.new(app.device_cookie_key, bytearray(f'device_cookie|{username}|{nonce}', 'utf-8'), 'sha256').digest()[20:]), 'utf-8') + return f'{username}${nonce}${sig}' diff --git a/core/admin/mailu/manage.py b/core/admin/mailu/manage.py index 62f214d3..937c9f49 100644 --- a/core/admin/mailu/manage.py +++ b/core/admin/mailu/manage.py @@ -1,40 +1,46 @@ -from mailu import models +""" Mailu command line interface +""" -from flask import current_app as app -from flask import cli as flask_cli - -import flask +import sys import os import socket import uuid + import click +import yaml + +from flask import current_app as app +from flask.cli import FlaskGroup, with_appcontext + +from mailu import models +from mailu.schemas import MailuSchema, Logger, RenderJSON db = models.db -@click.group() -def mailu(cls=flask_cli.FlaskGroup): +@click.group(cls=FlaskGroup, context_settings={'help_option_names': ['-?', '-h', '--help']}) +def mailu(): """ Mailu command line """ @mailu.command() -@flask_cli.with_appcontext +@with_appcontext def advertise(): """ Advertise this server against statistic services. """ - if os.path.isfile(app.config["INSTANCE_ID_PATH"]): - with open(app.config["INSTANCE_ID_PATH"], "r") as handle: + if os.path.isfile(app.config['INSTANCE_ID_PATH']): + with open(app.config['INSTANCE_ID_PATH'], 'r') as handle: instance_id = handle.read() else: instance_id = str(uuid.uuid4()) - with open(app.config["INSTANCE_ID_PATH"], "w") as handle: + with open(app.config['INSTANCE_ID_PATH'], 'w') as handle: handle.write(instance_id) - if not app.config["DISABLE_STATISTICS"]: + if not app.config['DISABLE_STATISTICS']: try: - socket.gethostbyname(app.config["STATS_ENDPOINT"].format(instance_id)) - except: + socket.gethostbyname(app.config['STATS_ENDPOINT'].format(instance_id)) + except OSError: pass @@ -42,57 +48,54 @@ def advertise(): @click.argument('localpart') @click.argument('domain_name') @click.argument('password') -@click.option('-m', '--mode') -@flask_cli.with_appcontext -def admin(localpart, domain_name, password, mode='create'): +@click.option('-m', '--mode', default='create', metavar='MODE', help='''\b'create' (default): create user. it's an error if user already exists +'ifmissing': only update password if user is missing +'update': create user or update password if user exists +''') +@with_appcontext +def admin(localpart, domain_name, password, mode): """ Create an admin user - 'mode' can be: - - 'create' (default) Will try to create user and will raise an exception if present - - 'ifmissing': if user exists, nothing happens, else it will be created - - 'update': user is created or, if it exists, its password gets updated """ + + if not mode in ('create', 'update', 'ifmissing'): + raise click.ClickException(f'invalid mode: {mode!r}') + domain = models.Domain.query.get(domain_name) if not domain: domain = models.Domain(name=domain_name) db.session.add(domain) - user = None - if mode == 'ifmissing' or mode == 'update': - email = '{}@{}'.format(localpart, domain_name) - user = models.User.query.get(email) - - if user and mode == 'ifmissing': - print('user %s exists, not updating' % email) + email = f'{localpart}@{domain_name}' + if user := models.User.query.get(email): + if mode == 'ifmissing': + print(f'user {email!r} exists, not updating') return - - if not user: + elif mode == 'update': + user.set_password(password) + db.session.commit() + print("updated admin password") + else: + raise click.ClickException(f'user {email!r} exists, not created') + else: user = models.User( localpart=localpart, domain=domain, global_admin=True ) - user.set_password(password) db.session.add(user) + user.set_password(password) db.session.commit() print("created admin user") - elif mode == 'update': - user.set_password(password) - db.session.commit() - print("updated admin password") - @mailu.command() @click.argument('localpart') @click.argument('domain_name') @click.argument('password') -@click.argument('hash_scheme', required=False) -@flask_cli.with_appcontext -def user(localpart, domain_name, password, hash_scheme=None): +@with_appcontext +def user(localpart, domain_name, password): """ Create a user """ - if hash_scheme is None: - hash_scheme = app.config['PASSWORD_SCHEME'] domain = models.Domain.query.get(domain_name) if not domain: domain = models.Domain(name=domain_name) @@ -102,7 +105,7 @@ def user(localpart, domain_name, password, hash_scheme=None): domain=domain, global_admin=False ) - user.set_password(password, hash_scheme=hash_scheme) + user.set_password(password) db.session.add(user) db.session.commit() @@ -111,19 +114,16 @@ def user(localpart, domain_name, password, hash_scheme=None): @click.argument('localpart') @click.argument('domain_name') @click.argument('password') -@click.argument('hash_scheme', required=False) -@flask_cli.with_appcontext -def password(localpart, domain_name, password, hash_scheme=None): +@with_appcontext +def password(localpart, domain_name, password): """ Change the password of an user """ - email = '{0}@{1}'.format(localpart, domain_name) - user = models.User.query.get(email) - if hash_scheme is None: - hash_scheme = app.config['PASSWORD_SCHEME'] + email = f'{localpart}@{domain_name}' + user = models.User.query.get(email) if user: - user.set_password(password, hash_scheme=hash_scheme) + user.set_password(password) else: - print("User " + email + " not found.") + print(f'User {email} not found.') db.session.commit() @@ -132,7 +132,7 @@ def password(localpart, domain_name, password, hash_scheme=None): @click.option('-u', '--max-users') @click.option('-a', '--max-aliases') @click.option('-q', '--max-quota-bytes') -@flask_cli.with_appcontext +@with_appcontext def domain(domain_name, max_users=-1, max_aliases=-1, max_quota_bytes=0): """ Create a domain """ @@ -148,13 +148,10 @@ def domain(domain_name, max_users=-1, max_aliases=-1, max_quota_bytes=0): @click.argument('localpart') @click.argument('domain_name') @click.argument('password_hash') -@click.argument('hash_scheme') -@flask_cli.with_appcontext -def user_import(localpart, domain_name, password_hash, hash_scheme = None): - """ Import a user along with password hash. +@with_appcontext +def user_import(localpart, domain_name, password_hash): + """ Import a user along with password hash """ - if hash_scheme is None: - hash_scheme = app.config['PASSWORD_SCHEME'] domain = models.Domain.query.get(domain_name) if not domain: domain = models.Domain(name=domain_name) @@ -164,19 +161,19 @@ def user_import(localpart, domain_name, password_hash, hash_scheme = None): domain=domain, global_admin=False ) - user.set_password(password_hash, hash_scheme=hash_scheme, raw=True) + user.set_password(password_hash, raw=True) db.session.add(user) db.session.commit() +# TODO: remove deprecated config_update function? @mailu.command() @click.option('-v', '--verbose') @click.option('-d', '--delete-objects') -@flask_cli.with_appcontext +@with_appcontext def config_update(verbose=False, delete_objects=False): - """sync configuration with data from YAML-formatted stdin""" - import yaml - import sys + """ Sync configuration with data from YAML (deprecated) + """ new_config = yaml.safe_load(sys.stdin) # print new_config domains = new_config.get('domains', []) @@ -196,13 +193,13 @@ def config_update(verbose=False, delete_objects=False): max_aliases=max_aliases, max_quota_bytes=max_quota_bytes) db.session.add(domain) - print("Added " + str(domain_config)) + print(f'Added {domain_config}') else: domain.max_users = max_users domain.max_aliases = max_aliases domain.max_quota_bytes = max_quota_bytes db.session.add(domain) - print("Updated " + str(domain_config)) + print(f'Updated {domain_config}') users = new_config.get('users', []) tracked_users = set() @@ -217,9 +214,8 @@ def config_update(verbose=False, delete_objects=False): localpart = user_config['localpart'] domain_name = user_config['domain'] password_hash = user_config.get('password_hash', None) - hash_scheme = user_config.get('hash_scheme', None) domain = models.Domain.query.get(domain_name) - email = '{0}@{1}'.format(localpart, domain_name) + email = f'{localpart}@{domain_name}' optional_params = {} for k in user_optional_params: if k in user_config: @@ -239,7 +235,7 @@ def config_update(verbose=False, delete_objects=False): else: for k in optional_params: setattr(user, k, optional_params[k]) - user.set_password(password_hash, hash_scheme=hash_scheme, raw=True) + user.set_password(password_hash, raw=True) db.session.add(user) aliases = new_config.get('aliases', []) @@ -249,13 +245,13 @@ def config_update(verbose=False, delete_objects=False): print(str(alias_config)) localpart = alias_config['localpart'] domain_name = alias_config['domain'] - if type(alias_config['destination']) is str: + if isinstance(alias_config['destination'], str): destination = alias_config['destination'].split(',') else: destination = alias_config['destination'] wildcard = alias_config.get('wildcard', False) domain = models.Domain.query.get(domain_name) - email = '{0}@{1}'.format(localpart, domain_name) + email = f'{localpart}@{domain_name}' if not domain: domain = models.Domain(name=domain_name) db.session.add(domain) @@ -285,7 +281,7 @@ def config_update(verbose=False, delete_objects=False): domain_name = manager_config['domain'] user_name = manager_config['user'] domain = models.Domain.query.get(domain_name) - manageruser = models.User.query.get(user_name + '@' + domain_name) + manageruser = models.User.query.get(f'{user_name}@{domain_name}') if manageruser not in domain.managers: domain.managers.append(manageruser) db.session.add(domain) @@ -294,26 +290,117 @@ def config_update(verbose=False, delete_objects=False): if delete_objects: for user in db.session.query(models.User).all(): - if not (user.email in tracked_users): + if not user.email in tracked_users: if verbose: - print("Deleting user: " + str(user.email)) + print(f'Deleting user: {user.email}') db.session.delete(user) for alias in db.session.query(models.Alias).all(): - if not (alias.email in tracked_aliases): + if not alias.email in tracked_aliases: if verbose: - print("Deleting alias: " + str(alias.email)) + print(f'Deleting alias: {alias.email}') db.session.delete(alias) for domain in db.session.query(models.Domain).all(): - if not (domain.name in tracked_domains): + if not domain.name in tracked_domains: if verbose: - print("Deleting domain: " + str(domain.name)) + print(f'Deleting domain: {domain.name}') db.session.delete(domain) db.session.commit() +@mailu.command() +@click.option('-v', '--verbose', count=True, help='Increase verbosity.') +@click.option('-s', '--secrets', is_flag=True, help='Show secret attributes in messages.') +@click.option('-d', '--debug', is_flag=True, help='Enable debug output.') +@click.option('-q', '--quiet', is_flag=True, help='Quiet mode - only show errors.') +@click.option('-c', '--color', is_flag=True, help='Force colorized output.') +@click.option('-u', '--update', is_flag=True, help='Update mode - merge input with existing config.') +@click.option('-n', '--dry-run', is_flag=True, help='Perform a trial run with no changes made.') +@click.argument('source', metavar='[FILENAME|-]', type=click.File(mode='r'), default=sys.stdin) +@with_appcontext +def config_import(verbose=0, secrets=False, debug=False, quiet=False, color=False, + update=False, dry_run=False, source=None): + """ Import configuration as YAML or JSON from stdin or file + """ + + log = Logger(want_color=color or None, can_color=sys.stdout.isatty(), secrets=secrets, debug=debug) + log.lexer = 'python' + log.strip = True + log.verbose = 0 if quiet else verbose + log.quiet = quiet + + context = { + 'import': True, + 'update': update, + 'clear': not update, + 'callback': log.track_serialize, + } + + schema = MailuSchema(only=MailuSchema.Meta.order, context=context) + + try: + # import source + with models.db.session.no_autoflush: + config = schema.loads(source) + # flush session to show/count all changes + if not quiet and (dry_run or verbose): + db.session.flush() + # check for duplicate domain names + config.check() + except Exception as exc: + if msg := log.format_exception(exc): + raise click.ClickException(msg) from exc + raise + + # don't commit when running dry + if dry_run: + log.changes('Dry run. Not committing changes.') + db.session.rollback() + else: + log.changes('Committing changes.') + db.session.commit() + + +@mailu.command() +@click.option('-f', '--full', is_flag=True, help='Include attributes with default value.') +@click.option('-s', '--secrets', is_flag=True, + help='Include secret attributes (dkim-key, passwords).') +@click.option('-d', '--dns', is_flag=True, help='Include dns records.') +@click.option('-c', '--color', is_flag=True, help='Force colorized output.') +@click.option('-o', '--output-file', 'output', default=sys.stdout, type=click.File(mode='w'), + help='Save configuration to file.') +@click.option('-j', '--json', 'as_json', is_flag=True, help='Export configuration in json format.') +@click.argument('only', metavar='[FILTER]...', nargs=-1) +@with_appcontext +def config_export(full=False, secrets=False, color=False, dns=False, output=None, as_json=False, only=None): + """ Export configuration as YAML or JSON to stdout or file + """ + + log = Logger(want_color=color or None, can_color=output.isatty()) + + only = only or MailuSchema.Meta.order + + context = { + 'full': full, + 'secrets': secrets, + 'dns': dns, + } + + try: + schema = MailuSchema(only=only, context=context) + if as_json: + schema.opts.render_module = RenderJSON + log.lexer = 'json' + log.strip = True + print(log.colorize(schema.dumps(models.MailuConfig())), file=output) + except Exception as exc: + if msg := log.format_exception(exc): + raise click.ClickException(msg) from exc + raise + + @mailu.command() @click.argument('email') -@flask_cli.with_appcontext +@with_appcontext def user_delete(email): """delete user""" user = models.User.query.get(email) @@ -324,7 +411,7 @@ def user_delete(email): @mailu.command() @click.argument('email') -@flask_cli.with_appcontext +@with_appcontext def alias_delete(email): """delete alias""" alias = models.Alias.query.get(email) @@ -338,7 +425,7 @@ def alias_delete(email): @click.argument('domain_name') @click.argument('destination') @click.option('-w', '--wildcard', is_flag=True) -@flask_cli.with_appcontext +@with_appcontext def alias(localpart, domain_name, destination, wildcard=False): """ Create an alias """ @@ -351,7 +438,7 @@ def alias(localpart, domain_name, destination, wildcard=False): domain=domain, wildcard=wildcard, destination=destination.split(','), - email="%s@%s" % (localpart, domain_name) + email=f'{localpart}@{domain_name}' ) db.session.add(alias) db.session.commit() @@ -362,7 +449,7 @@ def alias(localpart, domain_name, destination, wildcard=False): @click.argument('max_users') @click.argument('max_aliases') @click.argument('max_quota_bytes') -@flask_cli.with_appcontext +@with_appcontext def setlimits(domain_name, max_users, max_aliases, max_quota_bytes): """ Set domain limits """ @@ -377,16 +464,12 @@ def setlimits(domain_name, max_users, max_aliases, max_quota_bytes): @mailu.command() @click.argument('domain_name') @click.argument('user_name') -@flask_cli.with_appcontext +@with_appcontext def setmanager(domain_name, user_name='manager'): """ Make a user manager of a domain """ domain = models.Domain.query.get(domain_name) - manageruser = models.User.query.get(user_name + '@' + domain_name) + manageruser = models.User.query.get(f'{user_name}@{domain_name}') domain.managers.append(manageruser) db.session.add(domain) db.session.commit() - - -if __name__ == '__main__': - cli() diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index bbc00f2d..aedef62a 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -1,20 +1,34 @@ -from mailu import dkim +""" Mailu config storage model +""" -from sqlalchemy.ext import declarative -from passlib import context, hash -from datetime import datetime, date +import os +import smtplib +import json + +from datetime import date from email.mime import text -from flask import current_app as app +from itertools import chain import flask_sqlalchemy import sqlalchemy -import re +import passlib.context +import passlib.hash +import passlib.registry import time import os -import glob +import hmac import smtplib import idna -import dns +import dns.resolver +import dns.exception + +from flask import current_app as app +from sqlalchemy.ext import declarative +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.inspection import inspect +from werkzeug.utils import cached_property + +from mailu import dkim, utils db = flask_sqlalchemy.SQLAlchemy() @@ -25,69 +39,76 @@ class IdnaDomain(db.TypeDecorator): """ impl = db.String(80) + cache_ok = True + python_type = str def process_bind_param(self, value, dialect): - return idna.encode(value).decode("ascii").lower() + """ encode unicode domain name to punycode """ + return idna.encode(value.lower()).decode('ascii') def process_result_value(self, value, dialect): + """ decode punycode domain name to unicode """ return idna.decode(value) - class IdnaEmail(db.TypeDecorator): """ Stores a Unicode string in it's IDNA representation (ASCII only) """ impl = db.String(255) + cache_ok = True + python_type = str def process_bind_param(self, value, dialect): - try: - localpart, domain_name = value.split('@') - return "{0}@{1}".format( - localpart, - idna.encode(domain_name).decode('ascii'), - ).lower() - except ValueError: - pass + """ encode unicode domain part of email address to punycode """ + if not '@' in value: + raise ValueError('invalid email address (no "@")') + localpart, domain_name = value.lower().rsplit('@', 1) + if '@' in localpart: + raise ValueError('email local part must not contain "@"') + return f'{localpart}@{idna.encode(domain_name).decode("ascii")}' def process_result_value(self, value, dialect): - localpart, domain_name = value.split('@') - return "{0}@{1}".format( - localpart, - idna.decode(domain_name), - ) - + """ decode punycode domain part of email to unicode """ + localpart, domain_name = value.rsplit('@', 1) + return f'{localpart}@{idna.decode(domain_name)}' class CommaSeparatedList(db.TypeDecorator): """ Stores a list as a comma-separated string, compatible with Postfix. """ impl = db.String + cache_ok = True + python_type = list def process_bind_param(self, value, dialect): - if type(value) is not list: - raise TypeError("Shoud be a list") + """ join list of items to comma separated string """ + if not isinstance(value, (list, tuple, set)): + raise TypeError('Must be a list of strings') for item in value: - if "," in item: - raise ValueError("No item should contain a comma") - return ",".join(value) + if ',' in item: + raise ValueError('list item must not contain ","') + return ','.join(sorted(set(value))) def process_result_value(self, value, dialect): - return list(filter(bool, value.split(","))) if value else [] - + """ split comma separated string to list """ + return list(filter(bool, (item.strip() for item in value.split(',')))) if value else [] class JSONEncoded(db.TypeDecorator): - """Represents an immutable structure as a json-encoded string. + """ Represents an immutable structure as a json-encoded string. """ impl = db.String + cache_ok = True + python_type = str def process_bind_param(self, value, dialect): + """ encode data as json """ return json.dumps(value) if value else None def process_result_value(self, value, dialect): + """ decode json to data """ return json.loads(value) if value else None - class Base(db.Model): """ Base class for all models """ @@ -96,14 +117,43 @@ class Base(db.Model): metadata = sqlalchemy.schema.MetaData( naming_convention={ - "fk": "%(table_name)s_%(column_0_name)s_fkey", - "pk": "%(table_name)s_pkey" + 'fk': '%(table_name)s_%(column_0_name)s_fkey', + 'pk': '%(table_name)s_pkey' } ) created_at = db.Column(db.Date, nullable=False, default=date.today) updated_at = db.Column(db.Date, nullable=True, onupdate=date.today) - comment = db.Column(db.String(255), nullable=True) + comment = db.Column(db.String(255), nullable=True, default='') + + def __str__(self): + pkey = self.__table__.primary_key.columns.values()[0].name + if pkey == 'email': + # ugly hack for email declared attr. _email is not always up2date + return str(f'{self.localpart}@{self.domain_name}') + return str(getattr(self, pkey)) + + def __repr__(self): + return f'<{self.__class__.__name__} {str(self)!r}>' + + def __eq__(self, other): + if isinstance(other, self.__class__): + pkey = self.__table__.primary_key.columns.values()[0].name + this = getattr(self, pkey, None) + other = getattr(other, pkey, None) + return this is not None and other is not None and str(this) == str(other) + else: + return NotImplemented + + # we need hashable instances here for sqlalchemy to update collections + # in collections.bulk_replace, but auto-incrementing don't always have + # a valid primary key, in this case we use the object's id + __hashed = None + def __hash__(self): + if self.__hashed is None: + primary = getattr(self, self.__table__.primary_key.columns.values()[0].name) + self.__hashed = id(self) if primary is None else hash(primary) + return self.__hashed # Many-to-many association table for domain managers @@ -121,99 +171,189 @@ class Config(Base): value = db.Column(JSONEncoded) +def _save_dkim_keys(session): + """ store DKIM keys after commit """ + for obj in session.identity_map.values(): + if isinstance(obj, Domain): + obj.save_dkim_key() + class Domain(Base): """ A DNS domain that has mail addresses associated to it. """ - __tablename__ = "domain" + + __tablename__ = 'domain' name = db.Column(IdnaDomain, 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=-1) max_aliases = db.Column(db.Integer, nullable=False, default=-1) - max_quota_bytes = db.Column(db.BigInteger(), nullable=False, default=0) - signup_enabled = db.Column(db.Boolean(), nullable=False, default=False) + max_quota_bytes = db.Column(db.BigInteger, nullable=False, default=0) + signup_enabled = db.Column(db.Boolean, nullable=False, default=False) + + _dkim_key = None + _dkim_key_on_disk = None + + def _dkim_file(self): + """ return filename for active DKIM key """ + return app.config['DKIM_PATH'].format( + domain=self.name, + selector=app.config['DKIM_SELECTOR'] + ) + + def save_dkim_key(self): + """ save changed DKIM key to disk """ + if self._dkim_key != self._dkim_key_on_disk: + file_path = self._dkim_file() + if self._dkim_key: + with open(file_path, 'wb') as handle: + handle.write(self._dkim_key) + elif os.path.exists(file_path): + os.unlink(file_path) + self._dkim_key_on_disk = self._dkim_key + + @cached_property + def dns_mx(self): + """ return MX record for domain """ + hostname = app.config['HOSTNAME'] + return f'{self.name}. 600 IN MX 10 {hostname}.' + + @cached_property + def dns_spf(self): + """ return SPF record for domain """ + hostname = app.config['HOSTNAME'] + return f'{self.name}. 600 IN TXT "v=spf1 mx a:{hostname} ~all"' + + @property + def dns_dkim(self): + """ return DKIM record for domain """ + if self.dkim_key: + selector = app.config['DKIM_SELECTOR'] + txt = f'v=DKIM1; k=rsa; p={self.dkim_publickey}' + record = ' '.join(f'"{txt[p:p+250]}"' for p in range(0, len(txt), 250)) + return f'{selector}._domainkey.{self.name}. 600 IN TXT {record}' + + @cached_property + def dns_dmarc(self): + """ return DMARC record for domain """ + if self.dkim_key: + domain = app.config['DOMAIN'] + rua = app.config['DMARC_RUA'] + rua = f' rua=mailto:{rua}@{domain};' if rua else '' + ruf = app.config['DMARC_RUF'] + ruf = f' ruf=mailto:{ruf}@{domain};' if ruf else '' + return f'_dmarc.{self.name}. 600 IN TXT "v=DMARC1; p=reject;{rua}{ruf} adkim=s; aspf=s"' + + @cached_property + def dns_dmarc_report(self): + """ return DMARC report record for mailu server """ + if self.dkim_key: + domain = app.config['DOMAIN'] + return f'{self.name}._report._dmarc.{domain}. 600 IN TXT "v=DMARC1"' + + @cached_property + def dns_autoconfig(self): + """ return list of auto configuration records (RFC6186) """ + hostname = app.config['HOSTNAME'] + protocols = [ + ('submission', 587), + ('imap', 143), + ('pop3', 110), + ] + if app.config['TLS_FLAVOR'] != 'notls': + protocols.extend([ + ('imaps', 993), + ('pop3s', 995), + ]) + return list([ + f'_{proto}._tcp.{self.name}. 600 IN SRV 1 1 {port} {hostname}.' + for proto, port + in protocols + ]) + + @cached_property + def dns_tlsa(self): + """ return TLSA record for domain when using letsencrypt """ + hostname = app.config['HOSTNAME'] + if app.config['TLS_FLAVOR'] in ('letsencrypt', 'mail-letsencrypt'): + # current ISRG Root X1 (RSA 4096, O = Internet Security Research Group, CN = ISRG Root X1) @20210902 + return f'_25._tcp.{hostname}. 600 IN TLSA 2 1 1 0b9fa5a59eed715c26c1020c711b4f6ec42d58b0015e14337a39dad301c5afc3' @property def dkim_key(self): - 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() + """ return private DKIM key """ + if self._dkim_key is None: + file_path = self._dkim_file() + if os.path.exists(file_path): + with open(file_path, 'rb') as handle: + self._dkim_key = self._dkim_key_on_disk = handle.read() + else: + self._dkim_key = self._dkim_key_on_disk = b'' + return self._dkim_key if self._dkim_key else None @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) + """ set private DKIM key """ + old_key = self.dkim_key + self._dkim_key = value if value is not None else b'' + if self._dkim_key != old_key: + if not sqlalchemy.event.contains(db.session, 'after_commit', _save_dkim_keys): + sqlalchemy.event.listen(db.session, 'after_commit', _save_dkim_keys) @property def dkim_publickey(self): + """ return public part of DKIM key """ dkim_key = self.dkim_key if dkim_key: - return dkim.strip_key(self.dkim_key).decode("utf8") + return dkim.strip_key(dkim_key).decode('utf8') def generate_dkim_key(self): + """ generate new DKIM key """ self.dkim_key = dkim.gen_key() def has_email(self, localpart): - for email in self.users + self.aliases: + """ checks if localpart is configured for domain """ + localpart = localpart.lower() + for email in chain(self.users, self.aliases): if email.localpart == localpart: return True - else: - return False + return False def check_mx(self): + """ checks if MX record for domain points to mailu host """ try: - hostnames = app.config['HOSTNAMES'].split(',') + hostnames = set(app.config['HOSTNAMES'].split(',')) return any( - str(rset).split()[-1][:-1] in hostnames + rset.exchange.to_text().rstrip('.') in hostnames for rset in dns.resolver.query(self.name, 'MX') ) - except Exception as e: - return False - - def __str__(self): - return self.name - - def __eq__(self, other): - try: - return self.name == other.name - except AttributeError: + except dns.exception.DNSException: return False class Alternative(Base): """ Alternative name for a served domain. - The name "domain alias" was avoided to prevent some confusion. + The name "domain alias" was avoided to prevent some confusion. """ - __tablename__ = "alternative" + __tablename__ = 'alternative' name = db.Column(IdnaDomain, primary_key=True, nullable=False) domain_name = db.Column(IdnaDomain, db.ForeignKey(Domain.name)) domain = db.relationship(Domain, backref=db.backref('alternatives', cascade='all, delete-orphan')) - def __str__(self): - return self.name - class Relay(Base): """ Relayed mail domain. The domain is either relayed publicly or through a specified SMTP host. """ - __tablename__ = "relay" + __tablename__ = 'relay' name = db.Column(IdnaDomain, primary_key=True, nullable=False) smtp = db.Column(db.String(80), nullable=True) - def __str__(self): - return self.name - class Email(object): """ Abstraction for an email address (localpart and domain). @@ -223,6 +363,7 @@ class Email(object): @declarative.declared_attr def domain_name(cls): + """ the domain part of the email address """ return db.Column(IdnaDomain, db.ForeignKey(Domain.name), nullable=False, default=IdnaDomain) @@ -230,54 +371,82 @@ class Email(object): # It is however very useful for quick lookups without joining tables, # especially when the mail server is 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(IdnaEmail, - primary_key=True, nullable=False, - default=updater) + def _email(cls): + """ the complete email address (localpart@domain) """ + + def updater(ctx): + key = f'{cls.__tablename__}_email' + if key in ctx.current_parameters: + return ctx.current_parameters[key] + return '{localpart}@{domain_name}'.format_map(ctx.current_parameters) + + return db.Column('email', IdnaEmail, primary_key=True, nullable=False, onupdate=updater) + + # We need to keep email, localpart and domain_name in sync. + # But IMHO using email as primary key was not a good idea in the first place. + @hybrid_property + def email(self): + """ getter for email - gets _email """ + return self._email + + @email.setter + def email(self, value): + """ setter for email - sets _email, localpart and domain_name at once """ + self._email = value.lower() + self.localpart, self.domain_name = self._email.rsplit('@', 1) + + @staticmethod + def _update_localpart(target, value, *_): + if target.domain_name: + target._email = f'{value}@{target.domain_name}' + + @staticmethod + def _update_domain_name(target, value, *_): + if target.localpart: + target._email = f'{target.localpart}@{value}' + + @classmethod + def __declare_last__(cls): + # gets called after mappings are completed + sqlalchemy.event.listen(cls.localpart, 'set', cls._update_localpart, propagate=True) + sqlalchemy.event.listen(cls.domain_name, 'set', cls._update_domain_name, propagate=True) def sendmail(self, subject, body): - """ Send an email to the address. - """ - from_address = "{0}@{1}".format( - app.config['POSTMASTER'], - idna.encode(app.config['DOMAIN']).decode('ascii'), - ) + """ send an email to the address """ + f_addr = f'{app.config["POSTMASTER"]}@{idna.encode(app.config["DOMAIN"]).decode("ascii")}' with smtplib.SMTP(app.config['HOST_AUTHSMTP'], port=10025) as smtp: - to_address = "{0}@{1}".format( - self.localpart, - idna.encode(self.domain_name).decode('ascii'), - ) + to_address = f'{self.localpart}@{idna.encode(self.domain_name).decode("ascii")}' msg = text.MIMEText(body) msg['Subject'] = subject - msg['From'] = from_address + msg['From'] = f_addr msg['To'] = to_address - smtp.sendmail(from_address, [to_address], msg.as_string()) + smtp.sendmail(f_addr, [to_address], msg.as_string()) @classmethod def resolve_domain(cls, email): - localpart, domain_name = email.split('@', 1) if '@' in email else (None, email) - alternative = Alternative.query.get(domain_name) - if alternative: + """ resolves domain alternative to real domain """ + localpart, domain_name = email.rsplit('@', 1) if '@' in email else (None, email) + if alternative := Alternative.query.get(domain_name): domain_name = alternative.domain_name return (localpart, domain_name) @classmethod def resolve_destination(cls, localpart, domain_name, ignore_forward_keep=False): + """ return destination for email address localpart@domain_name """ + localpart_stripped = None stripped_alias = None - if os.environ.get('RECIPIENT_DELIMITER') in localpart: - localpart_stripped = localpart.rsplit(os.environ.get('RECIPIENT_DELIMITER'), 1)[0] + delim = os.environ.get('RECIPIENT_DELIMITER') + if delim in localpart: + localpart_stripped = localpart.rsplit(delim, 1)[0] - user = User.query.get('{}@{}'.format(localpart, domain_name)) + user = User.query.get(f'{localpart}@{domain_name}') if not user and localpart_stripped: - user = User.query.get('{}@{}'.format(localpart_stripped, domain_name)) + user = User.query.get(f'{localpart_stripped}@{domain_name}') + if user: - email = '{}@{}'.format(localpart, domain_name) + email = f'{localpart}@{domain_name}' if user.forward_enabled: destination = user.forward_destination @@ -285,55 +454,59 @@ class Email(object): destination.append(email) else: destination = [email] + return destination pure_alias = Alias.resolve(localpart, domain_name) - stripped_alias = Alias.resolve(localpart_stripped, domain_name) if pure_alias and not pure_alias.wildcard: return pure_alias.destination - elif stripped_alias: + + if stripped_alias := Alias.resolve(localpart_stripped, domain_name): return stripped_alias.destination - elif pure_alias: + + if pure_alias: return pure_alias.destination - def __str__(self): - return self.email + return None class User(Base, Email): """ A user is an email address that has a password to access a mailbox. """ - __tablename__ = "user" + + __tablename__ = 'user' + _ctx = None + _credential_cache = {} 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.BigInteger(), nullable=False, default=10**9) - quota_bytes_used = db.Column(db.BigInteger(), nullable=False, default=0) - global_admin = db.Column(db.Boolean(), nullable=False, default=False) - enabled = db.Column(db.Boolean(), nullable=False, default=True) + quota_bytes = db.Column(db.BigInteger, nullable=False, default=10**9) + quota_bytes_used = db.Column(db.BigInteger, nullable=False, default=0) + global_admin = db.Column(db.Boolean, nullable=False, default=False) + enabled = db.Column(db.Boolean, nullable=False, default=True) # Features - enable_imap = db.Column(db.Boolean(), nullable=False, default=True) - enable_pop = db.Column(db.Boolean(), nullable=False, default=True) + 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(CommaSeparatedList(), nullable=True, default=[]) - forward_keep = db.Column(db.Boolean(), nullable=False, default=True) - reply_enabled = db.Column(db.Boolean(), nullable=False, default=False) + forward_enabled = db.Column(db.Boolean, nullable=False, default=False) + forward_destination = db.Column(CommaSeparatedList, nullable=True, default=list) + forward_keep = db.Column(db.Boolean, nullable=False, default=True) + 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) + reply_body = db.Column(db.Text, nullable=True, default=None) reply_startdate = db.Column(db.Date, nullable=False, default=date(1900, 1, 1)) reply_enddate = db.Column(db.Date, nullable=False, default=date(2999, 12, 31)) # 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) + 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) # Flask-login attributes is_authenticated = True @@ -341,20 +514,23 @@ class User(Base, Email): is_anonymous = False def get_id(self): + """ return users email address """ return self.email @property def destination(self): + """ returns comma separated string of destinations """ if self.forward_enabled: - result = self.forward_destination + result = list(self.forward_destination) if self.forward_keep: - result += ',' + self.email - return result + result.append(self.email) + return ','.join(result) else: return self.email @property def reply_active(self): + """ returns status of autoreply function """ now = date.today() return ( self.reply_enabled and @@ -362,48 +538,90 @@ class User(Base, Email): self.reply_enddate > now ) - scheme_dict = {'PBKDF2': "pbkdf2_sha512", - 'BLF-CRYPT': "bcrypt", - 'SHA512-CRYPT': "sha512_crypt", - 'SHA256-CRYPT': "sha256_crypt", - 'MD5-CRYPT': "md5_crypt", - 'CRYPT': "des_crypt"} - - def get_password_context(self): - return context.CryptContext( - schemes=self.scheme_dict.values(), - default=self.scheme_dict[app.config['PASSWORD_SCHEME']], + @property + def sender_limiter(self): + return utils.limiter.get_limiter( + app.config["MESSAGE_RATELIMIT"], "sender", self.email ) + @classmethod + def get_password_context(cls): + """ create password context for hashing and verification + """ + if cls._ctx: + return cls._ctx + + # compile schemes + # - skip scrypt (throws a warning if the native wheels aren't found) + # - skip plaintext schemes (will be misidentified) + schemes = [ + scheme for scheme in passlib.registry.list_crypt_handlers() + if not (scheme == 'scrypt' or scheme.endswith('plaintext')) + ] + cls._ctx = passlib.context.CryptContext( + schemes=schemes, + default='bcrypt_sha256', + bcrypt_sha256__rounds=app.config['CREDENTIAL_ROUNDS'], + deprecated='auto' + ) + return cls._ctx + def check_password(self, password): - context = self.get_password_context() - reference = re.match('({[^}]+})?(.*)', self.password).group(2) - result = context.verify(password, reference) - if result and context.identify(reference) != context.default_scheme(): - self.set_password(password) + """ verifies password against stored hash + and updates hash if outdated + """ + if password == '': + return False + cache_result = self._credential_cache.get(self.get_id()) + current_salt = self.password.split('$')[3] if len(self.password.split('$')) == 5 else None + if cache_result and current_salt: + cache_salt, cache_hash = cache_result + if cache_salt == current_salt: + return passlib.hash.pbkdf2_sha256.verify(password, cache_hash) + else: + # the cache is local per gunicorn; the password has changed + # so the local cache can be invalidated + del self._credential_cache[self.get_id()] + reference = self.password + # strip {scheme} if that's something mailu has added + # passlib will identify *crypt based hashes just fine + # on its own + if reference.startswith(('{PBKDF2}', '{BLF-CRYPT}', '{SHA512-CRYPT}', '{SHA256-CRYPT}', '{MD5-CRYPT}', '{CRYPT}')): + reference = reference.split('}', 1)[1] + + result, new_hash = User.get_password_context().verify_and_update(password, reference) + if new_hash: + self.password = new_hash db.session.add(self) db.session.commit() + + if result: + """The credential cache uses a low number of rounds to be fast. +While it's not meant to be persisted to cold-storage, no additional measures +are taken to ensure it isn't (mlock(), encrypted swap, ...) on the basis that +we have little control over GC and string interning anyways. + + An attacker that can dump the process' memory is likely to find credentials +in clear-text regardless of the presence of the cache. + """ + self._credential_cache[self.get_id()] = (self.password.split('$')[3], passlib.hash.pbkdf2_sha256.using(rounds=1).hash(password)) return result - def set_password(self, password, hash_scheme=None, raw=False): - """Set password for user with specified encryption scheme - @password: plain text password to encrypt (if raw == True the hash itself) + def set_password(self, password, raw=False): + """ Set password for user + @password: plain text password to encrypt (or, if raw is True: the hash itself) """ - if hash_scheme is None: - hash_scheme = app.config['PASSWORD_SCHEME'] - # for the list of hash schemes see https://wiki2.dovecot.org/Authentication/PasswordSchemes - if raw: - self.password = '{'+hash_scheme+'}' + password - else: - self.password = '{'+hash_scheme+'}' + self.get_password_context().encrypt(password, self.scheme_dict[hash_scheme]) + self.password = password if raw else User.get_password_context().hash(password) def get_managed_domains(self): + """ return list of domains this user can manage """ if self.global_admin: return Domain.query.all() else: return self.manager_of def get_managed_emails(self, include_aliases=True): + """ returns list of email addresses this user can manage """ emails = [] for domain in self.get_managed_domains(): emails.extend(domain.users) @@ -412,32 +630,46 @@ class User(Base, Email): return emails def send_welcome(self): - if app.config["WELCOME"]: - self.sendmail(app.config["WELCOME_SUBJECT"], - app.config["WELCOME_BODY"]) + """ send welcome email to user """ + if app.config['WELCOME']: + self.sendmail(app.config['WELCOME_SUBJECT'], app.config['WELCOME_BODY']) @classmethod def get(cls, email): + """ find user object for email address """ return cls.query.get(email) @classmethod def login(cls, email, password): + """ login user when enabled and password is valid """ user = cls.query.get(email) return user if (user and user.enabled and user.check_password(password)) else None + @classmethod + def get_temp_token(cls, email): + user = cls.query.get(email) + return hmac.new(app.temp_token_key, bytearray("{}|{}".format(time.strftime('%Y%m%d'), email), 'utf-8'), 'sha256').hexdigest() if (user and user.enabled) else None + + def verify_temp_token(self, token): + return hmac.compare_digest(self.get_temp_token(self.email), token) + + class Alias(Base, Email): """ An alias is an email address that redirects to some destination. """ - __tablename__ = "alias" + + __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=[]) + wildcard = db.Column(db.Boolean, nullable=False, default=False) + destination = db.Column(CommaSeparatedList, nullable=False, default=list) @classmethod def resolve(cls, localpart, domain_name): + """ find aliases matching email address localpart@domain_name """ + alias_preserve_case = cls.query.filter( sqlalchemy.and_(cls.domain_name == domain_name, sqlalchemy.or_( @@ -446,7 +678,7 @@ class Alias(Base, Email): cls.localpart == localpart ), sqlalchemy.and_( cls.wildcard == True, - sqlalchemy.bindparam("l", localpart).like(cls.localpart) + sqlalchemy.bindparam('l', localpart).like(cls.localpart) ) ) ) @@ -461,30 +693,33 @@ class Alias(Base, Email): sqlalchemy.func.lower(cls.localpart) == localpart_lower ), sqlalchemy.and_( cls.wildcard == True, - sqlalchemy.bindparam("l", localpart_lower).like(sqlalchemy.func.lower(cls.localpart)) + sqlalchemy.bindparam('l', localpart_lower).like( + sqlalchemy.func.lower(cls.localpart)) ) ) ) - ).order_by(cls.wildcard, sqlalchemy.func.char_length(sqlalchemy.func.lower(cls.localpart)).desc()).first() + ).order_by(cls.wildcard, sqlalchemy.func.char_length( + sqlalchemy.func.lower(cls.localpart)).desc()).first() if alias_preserve_case and alias_lower_case: - if alias_preserve_case.wildcard: - return alias_lower_case - else: - return alias_preserve_case - elif alias_preserve_case and not alias_lower_case: + return alias_lower_case if alias_preserve_case.wildcard else alias_preserve_case + + if alias_preserve_case and not alias_lower_case: return alias_preserve_case - elif alias_lower_case and not alias_preserve_case: + + if alias_lower_case and not alias_preserve_case: return alias_lower_case - else: - return None + + return None + class Token(Base): """ A token is an application password for a given user. """ - __tablename__ = "token" - id = db.Column(db.Integer(), primary_key=True) + __tablename__ = 'token' + + 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, @@ -493,32 +728,259 @@ class Token(Base): ip = db.Column(db.String(255)) def check_password(self, password): - return hash.sha256_crypt.verify(password, self.password) + """ verifies password against stored hash + and updates hash if outdated + """ + if self.password.startswith("$5$"): + if passlib.hash.sha256_crypt.verify(password, self.password): + self.set_password(password) + db.session.add(self) + db.session.commit() + return True + return False + return passlib.hash.pbkdf2_sha256.verify(password, self.password) def set_password(self, password): - self.password = hash.sha256_crypt.using(rounds=1000).hash(password) + """ sets password using pbkdf2_sha256 (1 round) """ + # tokens have 128bits of entropy, they are not bruteforceable + self.password = passlib.hash.pbkdf2_sha256.using(rounds=1).hash(password) - def __str__(self): - return self.comment + def __repr__(self): + return f'' class Fetch(Base): - """ A fetched account is a repote POP/IMAP account fetched into a local + """ A fetched account is a remote POP/IMAP account fetched into a local account. """ - __tablename__ = "fetch" - id = db.Column(db.Integer(), primary_key=True) + __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) + port = db.Column(db.Integer, nullable=False) + tls = db.Column(db.Boolean, nullable=False, default=False) username = db.Column(db.String(255), nullable=False) password = db.Column(db.String(255), nullable=False) - keep = db.Column(db.Boolean(), nullable=False) + keep = db.Column(db.Boolean, nullable=False, default=False) last_check = db.Column(db.DateTime, nullable=True) error = db.Column(db.String(1023), nullable=True) + + def __repr__(self): + return ( + f'' + ) + + +class MailuConfig: + """ Class which joins whole Mailu config for dumping + and loading + """ + + class MailuCollection: + """ Provides dict- and list-like access to instances + of a sqlalchemy model + """ + + def __init__(self, model : db.Model): + self.model = model + + def __repr__(self): + return f'<{self.model.__name__}-Collection>' + + @cached_property + def _items(self): + return { + inspect(item).identity: item + for item in self.model.query.all() + } + + def __len__(self): + return len(self._items) + + def __iter__(self): + return iter(self._items.values()) + + def __getitem__(self, key): + return self._items[key] + + def __setitem__(self, key, item): + if not isinstance(item, self.model): + raise TypeError(f'expected {self.model.name}') + if key != inspect(item).identity: + raise ValueError(f'item identity != key {key!r}') + self._items[key] = item + + def __delitem__(self, key): + del self._items[key] + + def append(self, item, update=False): + """ list-like append """ + if not isinstance(item, self.model): + raise TypeError(f'expected {self.model.name}') + key = inspect(item).identity + if key in self._items: + if not update: + raise ValueError(f'item {key!r} already present in collection') + self._items[key] = item + + def extend(self, items, update=False): + """ list-like extend """ + add = {} + for item in items: + if not isinstance(item, self.model): + raise TypeError(f'expected {self.model.name}') + key = inspect(item).identity + if not update and key in self._items: + raise ValueError(f'item {key!r} already present in collection') + add[key] = item + self._items.update(add) + + def pop(self, *args): + """ list-like (no args) and dict-like (1 or 2 args) pop """ + if args: + if len(args) > 2: + raise TypeError(f'pop expected at most 2 arguments, got {len(args)}') + return self._items.pop(*args) + else: + return self._items.popitem()[1] + + def popitem(self): + """ dict-like popitem """ + return self._items.popitem() + + def remove(self, item): + """ list-like remove """ + if not isinstance(item, self.model): + raise TypeError(f'expected {self.model.name}') + key = inspect(item).identity + if not key in self._items: + raise ValueError(f'item {key!r} not found in collection') + del self._items[key] + + def clear(self): + """ dict-like clear """ + while True: + try: + self.pop() + except IndexError: + break + + def update(self, items): + """ dict-like update """ + for key, item in items: + if not isinstance(item, self.model): + raise TypeError(f'expected {self.model.name}') + if key != inspect(item).identity: + raise ValueError(f'item identity != key {key!r}') + self._items.update(items) + + def setdefault(self, key, item=None): + """ dict-like setdefault """ + if key in self._items: + return self._items[key] + if item is None: + return None + if not isinstance(item, self.model): + raise TypeError(f'expected {self.model.name}') + if key != inspect(item).identity: + raise ValueError(f'item identity != key {key!r}') + self._items[key] = item + return item + + def __init__(self): + + # section-name -> attr + self._sections = { + name: getattr(self, name) + for name in dir(self) + if isinstance(getattr(self, name), self.MailuCollection) + } + + # known models + self._models = tuple(section.model for section in self._sections.values()) + + # model -> attr + self._sections.update({ + section.model: section for section in self._sections.values() + }) + + def _get_model(self, section): + if section is None: + return None + model = self._sections.get(section) + if model is None: + raise ValueError(f'Invalid section: {section!r}') + if isinstance(model, self.MailuCollection): + return model.model + return model + + def _add(self, items, section, update): + + model = self._get_model(section) + if isinstance(items, self._models): + items = [items] + elif not hasattr(items, '__iter__'): + raise ValueError(f'{items!r} is not iterable') + + for item in items: + if model is not None and not isinstance(item, model): + what = item.__class__.__name__.capitalize() + raise ValueError(f'{what} can not be added to section {section!r}') + self._sections[type(item)].append(item, update=update) + + def add(self, items, section=None): + """ add item to config """ + self._add(items, section, update=False) + + def update(self, items, section=None): + """ add or replace item in config """ + self._add(items, section, update=True) + + def remove(self, items, section=None): + """ remove item from config """ + model = self._get_model(section) + if isinstance(items, self._models): + items = [items] + elif not hasattr(items, '__iter__'): + raise ValueError(f'{items!r} is not iterable') + + for item in items: + if isinstance(item, str): + if section is None: + raise ValueError(f'Cannot remove key {item!r} without section') + del self._sections[model][item] + elif model is not None and not isinstance(item, model): + what = item.__class__.__name__.capitalize() + raise ValueError(f'{what} can not be removed from section {section!r}') + self._sections[type(item)].remove(item,) + + def clear(self, models=None): + """ remove complete configuration """ + for model in self._models: + if models is None or model in models: + db.session.query(model).delete() + + def check(self): + """ check for duplicate domain names """ + dup = set() + for fqdn in chain( + db.session.query(Domain.name), + db.session.query(Alternative.name), + db.session.query(Relay.name) + ): + if fqdn in dup: + raise ValueError(f'Duplicate domain name: {fqdn}') + dup.add(fqdn) + + domain = MailuCollection(Domain) + user = MailuCollection(User) + alias = MailuCollection(Alias) + relay = MailuCollection(Relay) + config = MailuCollection(Config) diff --git a/core/admin/mailu/schemas.py b/core/admin/mailu/schemas.py new file mode 100644 index 00000000..00cbf464 --- /dev/null +++ b/core/admin/mailu/schemas.py @@ -0,0 +1,1274 @@ +""" Mailu marshmallow fields and schema +""" + +from copy import deepcopy +from collections import Counter +from datetime import timezone + +import json +import logging +import yaml + +import sqlalchemy + +from marshmallow import pre_load, post_load, post_dump, fields, Schema +from marshmallow.utils import ensure_text_type +from marshmallow.exceptions import ValidationError +from marshmallow_sqlalchemy import SQLAlchemyAutoSchemaOpts +from marshmallow_sqlalchemy.fields import RelatedList + +from flask_marshmallow import Marshmallow + +from OpenSSL import crypto + +from pygments import highlight +from pygments.token import Token +from pygments.lexers import get_lexer_by_name +from pygments.lexers.data import YamlLexer +from pygments.formatters import get_formatter_by_name + +from mailu import models, dkim + + +ma = Marshmallow() + + +### import logging and schema colorization ### + +_model2schema = {} + +def get_schema(cls=None): + """ return schema class for model """ + if cls is None: + return _model2schema.values() + return _model2schema.get(cls) + +def mapped(cls): + """ register schema in model2schema map """ + _model2schema[cls.Meta.model] = cls + return cls + +class Logger: + """ helps with counting and colorizing + imported and exported data + """ + + class MyYamlLexer(YamlLexer): + """ colorize yaml constants and integers """ + def get_tokens(self, text, unfiltered=False): + for typ, value in super().get_tokens(text, unfiltered): + if typ is Token.Literal.Scalar.Plain: + if value in {'true', 'false', 'null'}: + typ = Token.Keyword.Constant + elif value == HIDDEN: + typ = Token.Error + else: + try: + int(value, 10) + except ValueError: + try: + float(value) + except ValueError: + pass + else: + typ = Token.Literal.Number.Float + else: + typ = Token.Literal.Number.Integer + yield typ, value + + def __init__(self, want_color=None, can_color=False, debug=False, secrets=False): + + self.lexer = 'yaml' + self.formatter = 'terminal' + self.strip = False + self.verbose = 0 + self.quiet = False + self.secrets = secrets + self.debug = debug + self.print = print + + self.color = want_color or can_color + + self._counter = Counter() + self._schemas = {} + + # log contexts + self._diff_context = { + 'full': True, + 'secrets': secrets, + } + log_context = { + 'secrets': secrets, + } + + # register listeners + for schema in get_schema(): + model = schema.Meta.model + self._schemas[model] = schema(context=log_context) + sqlalchemy.event.listen(model, 'after_insert', self._listen_insert) + sqlalchemy.event.listen(model, 'after_update', self._listen_update) + sqlalchemy.event.listen(model, 'after_delete', self._listen_delete) + + # special listener for dkim_key changes + # TODO: _listen_dkim can be removed when dkim keys are stored in database + self._dedupe_dkim = set() + sqlalchemy.event.listen(models.db.session, 'after_flush', self._listen_dkim) + + # register debug logger for sqlalchemy + if self.debug: + logging.basicConfig() + logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) + + def _log(self, action, target, message=None): + if message is None: + try: + message = self._schemas[target.__class__].dump(target) + except KeyError: + message = target + if not isinstance(message, str): + message = repr(message) + self.print(f'{action} {target.__table__}: {self.colorize(message)}') + + def _listen_insert(self, mapper, connection, target): # pylint: disable=unused-argument + """ callback method to track import """ + self._counter.update([('Created', target.__table__.name)]) + if self.verbose: + self._log('Created', target) + + def _listen_update(self, mapper, connection, target): # pylint: disable=unused-argument + """ callback method to track import """ + + changes = {} + inspection = sqlalchemy.inspect(target) + for attr in sqlalchemy.orm.class_mapper(target.__class__).column_attrs: + history = getattr(inspection.attrs, attr.key).history + if history.has_changes() and history.deleted: + before = history.deleted[-1] + after = getattr(target, attr.key) + # we don't have ordered lists + if isinstance(before, list): + before = set(before) + if isinstance(after, list): + after = set(after) + # TODO: this can be removed when comment is not nullable in model + if attr.key == 'comment' and not before and not after: + pass + # only remember changed keys + elif before != after: + if self.verbose: + changes[str(attr.key)] = (before, after) + else: + break + + if self.verbose: + # use schema to log changed attributes + schema = get_schema(target.__class__) + only = set(changes.keys()) & set(schema().fields.keys()) + if only: + for key, value in schema( + only=only, + context=self._diff_context + ).dump(target).items(): + before, after = changes[key] + if value == HIDDEN: + before = HIDDEN if before else before + after = HIDDEN if after else after + else: + # also hide this + after = value + self._log('Modified', target, f'{str(target)!r} {key}: {before!r} -> {after!r}') + + if changes: + self._counter.update([('Modified', target.__table__.name)]) + + def _listen_delete(self, mapper, connection, target): # pylint: disable=unused-argument + """ callback method to track import """ + self._counter.update([('Deleted', target.__table__.name)]) + if self.verbose: + self._log('Deleted', target) + + # TODO: _listen_dkim can be removed when dkim keys are stored in database + def _listen_dkim(self, session, flush_context): # pylint: disable=unused-argument + """ callback method to track import """ + for target in session.identity_map.values(): + # look at Domains originally loaded from db + if not isinstance(target, models.Domain) or not target._sa_instance_state.load_path: + continue + before = target._dkim_key_on_disk + after = target._dkim_key + # "de-dupe" messages; this event is fired at every flush + if before == after or (target, before, after) in self._dedupe_dkim: + continue + self._dedupe_dkim.add((target, before, after)) + self._counter.update([('Modified', target.__table__.name)]) + if self.verbose: + if self.secrets: + before = before.decode('ascii', 'ignore') + after = after.decode('ascii', 'ignore') + else: + before = HIDDEN if before else '' + after = HIDDEN if after else '' + self._log('Modified', target, f'{str(target)!r} dkim_key: {before!r} -> {after!r}') + + def track_serialize(self, obj, item, backref=None): + """ callback method to track import """ + # called for backref modification? + if backref is not None: + self._log( + 'Modified', item, '{target!r} {key}: {before!r} -> {after!r}'.format_map(backref)) + return + # show input data? + if self.verbose < 2: + return + # hide secrets in data + if not self.secrets: + item = self._schemas[obj.opts.model].hide(item) + if 'hash_password' in item: + item['password'] = HIDDEN + if 'fetches' in item: + for fetch in item['fetches']: + fetch['password'] = HIDDEN + self._log('Handling', obj.opts.model, item) + + def changes(self, *messages, **kwargs): + """ show changes gathered in counter """ + if self.quiet: + return + if self._counter: + changes = [] + last = None + for (action, what), count in sorted(self._counter.items()): + if action != last: + if last: + changes.append('/') + changes.append(f'{action}:') + last = action + changes.append(f'{what}({count})') + else: + changes = ['No changes.'] + self.print(*messages, *changes, **kwargs) + + def _format_errors(self, store, path=None): + + res = [] + if path is None: + path = [] + for key in sorted(store): + location = path + [str(key)] + value = store[key] + if isinstance(value, dict): + res.extend(self._format_errors(value, location)) + else: + for message in value: + res.append((".".join(location), message)) + + if path: + return res + + maxlen = max(len(loc) for loc, msg in res) + res = [f' - {loc.ljust(maxlen)} : {msg}' for loc, msg in res] + errors = f'{len(res)} error{["s",""][len(res)==1]}' + res.insert(0, f'[ValidationError] {errors} occurred during input validation') + + return '\n'.join(res) + + def _is_validation_error(self, exc): + """ walk traceback to extract invalid field from marshmallow """ + path = [] + trace = exc.__traceback__ + while trace: + if trace.tb_frame.f_code.co_name == '_serialize': + if 'attr' in trace.tb_frame.f_locals: + path.append(trace.tb_frame.f_locals['attr']) + elif trace.tb_frame.f_code.co_name == '_init_fields': + spec = ', '.join( + '.'.join(path + [key]) + for key in trace.tb_frame.f_locals['invalid_fields']) + return f'Invalid filter: {spec}' + trace = trace.tb_next + return None + + def format_exception(self, exc): + """ format ValidationErrors and other exceptions when not debugging """ + if isinstance(exc, ValidationError): + return self._format_errors(exc.messages) + if isinstance(exc, ValueError): + if msg := self._is_validation_error(exc): + return msg + if self.debug: + return None + msg = ' '.join(str(exc).split()) + return f'[{exc.__class__.__name__}] {msg}' + + colorscheme = { + Token: ('', ''), + Token.Name.Tag: ('cyan', 'cyan'), + Token.Literal.Scalar: ('green', 'green'), + Token.Literal.String: ('green', 'green'), + Token.Name.Constant: ('green', 'green'), # multiline strings + Token.Keyword.Constant: ('magenta', 'magenta'), + Token.Literal.Number: ('magenta', 'magenta'), + Token.Error: ('red', 'red'), + Token.Name: ('red', 'red'), + Token.Operator: ('red', 'red'), + } + + def colorize(self, data, lexer=None, formatter=None, color=None, strip=None): + """ add ANSI color to data """ + + if color is False or not self.color: + return data + + lexer = lexer or self.lexer + lexer = Logger.MyYamlLexer() if lexer == 'yaml' else get_lexer_by_name(lexer) + formatter = get_formatter_by_name(formatter or self.formatter, colorscheme=self.colorscheme) + if strip is None: + strip = self.strip + + res = highlight(data, lexer, formatter) + if strip: + return res.rstrip('\n') + return res + + +### marshmallow render modules ### + +# hidden attributes +class _Hidden: + def __bool__(self): + return False + def __copy__(self): + return self + def __deepcopy__(self, _): + return self + def __eq__(self, other): + return str(other) == '' + def __repr__(self): + return '' + __str__ = __repr__ + +yaml.add_representer( + _Hidden, + lambda dumper, data: dumper.represent_data(str(data)) +) + +HIDDEN = _Hidden() + +# multiline attributes +class _Multiline(str): + pass + +yaml.add_representer( + _Multiline, + lambda dumper, data: dumper.represent_scalar(u'tag:yaml.org,2002:str', data, style='|') + +) + +# yaml render module +class RenderYAML: + """ Marshmallow YAML Render Module + """ + + class SpacedDumper(yaml.Dumper): + """ YAML Dumper to add a newline between main sections + and double the indent used + """ + + def write_line_break(self, data=None): + super().write_line_break(data) + if len(self.indents) == 1: + super().write_line_break() + + def increase_indent(self, flow=False, indentless=False): + return super().increase_indent(flow, False) + + @staticmethod + def _augment(kwargs, defaults): + """ add defaults to kwargs if missing + """ + for key, value in defaults.items(): + if key not in kwargs: + kwargs[key] = value + + _load_defaults = {} + @classmethod + def loads(cls, *args, **kwargs): + """ load yaml data from string + """ + cls._augment(kwargs, cls._load_defaults) + return yaml.safe_load(*args, **kwargs) + + _dump_defaults = { + 'Dumper': SpacedDumper, + 'default_flow_style': False, + 'allow_unicode': True, + 'sort_keys': False, + } + @classmethod + def dumps(cls, *args, **kwargs): + """ dump data to yaml string + """ + cls._augment(kwargs, cls._dump_defaults) + return yaml.dump(*args, **kwargs) + +# json encoder +class JSONEncoder(json.JSONEncoder): + """ JSONEncoder supporting serialization of HIDDEN """ + def default(self, o): + """ serialize HIDDEN """ + if isinstance(o, _Hidden): + return str(o) + return json.JSONEncoder.default(self, o) + +# json render module +class RenderJSON: + """ Marshmallow JSON Render Module + """ + + @staticmethod + def _augment(kwargs, defaults): + """ add defaults to kwargs if missing + """ + for key, value in defaults.items(): + if key not in kwargs: + kwargs[key] = value + + _load_defaults = {} + @classmethod + def loads(cls, *args, **kwargs): + """ load json data from string + """ + cls._augment(kwargs, cls._load_defaults) + return json.loads(*args, **kwargs) + + _dump_defaults = { + 'separators': (',',':'), + 'cls': JSONEncoder, + } + @classmethod + def dumps(cls, *args, **kwargs): + """ dump data to json string + """ + cls._augment(kwargs, cls._dump_defaults) + return json.dumps(*args, **kwargs) + + +### marshmallow: custom fields ### + +def _rfc3339(datetime): + """ dump datetime according to rfc3339 """ + if datetime.tzinfo is None: + datetime = datetime.astimezone(timezone.utc) + res = datetime.isoformat() + if res.endswith('+00:00'): + return f'{res[:-6]}Z' + return res + +fields.DateTime.SERIALIZATION_FUNCS['rfc3339'] = _rfc3339 +fields.DateTime.DESERIALIZATION_FUNCS['rfc3339'] = fields.DateTime.DESERIALIZATION_FUNCS['iso'] +fields.DateTime.DEFAULT_FORMAT = 'rfc3339' + +class LazyStringField(fields.String): + """ Field that serializes a "false" value to the empty string + """ + + def _serialize(self, value, attr, obj, **kwargs): + """ serialize None to the empty string + """ + return value if value else '' + +class CommaSeparatedListField(fields.Raw): + """ Deserialize a string containing comma-separated values to + a list of strings + """ + + default_error_messages = { + "invalid": "Not a valid string or list.", + "invalid_utf8": "Not a valid utf-8 string or list.", + } + + def _deserialize(self, value, attr, data, **kwargs): + """ deserialize comma separated string to list of strings + """ + + # empty + if not value: + return [] + + # handle list + if isinstance(value, list): + try: + value = [ensure_text_type(item) for item in value] + except UnicodeDecodeError as exc: + raise self.make_error("invalid_utf8") from exc + + # handle text + else: + if not isinstance(value, (str, bytes)): + raise self.make_error("invalid") + try: + value = ensure_text_type(value) + except UnicodeDecodeError as exc: + raise self.make_error("invalid_utf8") from exc + else: + value = filter(bool, (item.strip() for item in value.split(','))) + + return list(value) + + +class DkimKeyField(fields.String): + """ Serialize a dkim key to a multiline string and + deserialize a dkim key data as string or list of strings + to a valid dkim key + """ + + default_error_messages = { + "invalid": "Not a valid string or list.", + "invalid_utf8": "Not a valid utf-8 string or list.", + } + + def _serialize(self, value, attr, obj, **kwargs): + """ serialize dkim key as multiline string + """ + + # map empty string and None to None + if not value: + return '' + + # return multiline string + return _Multiline(value.decode('utf-8')) + + def _wrap_key(self, begin, data, end): + """ generator to wrap key into RFC 7468 format """ + yield begin + pos = 0 + while pos < len(data): + yield data[pos:pos+64] + pos += 64 + yield end + yield '' + + def _deserialize(self, value, attr, data, **kwargs): + """ deserialize a string or list of strings to dkim key data + with verification + """ + + # convert list to str + if isinstance(value, list): + try: + value = ''.join(ensure_text_type(item) for item in value).strip() + except UnicodeDecodeError as exc: + raise self.make_error("invalid_utf8") from exc + + # only text is allowed + else: + if not isinstance(value, (str, bytes)): + raise self.make_error("invalid") + try: + value = ensure_text_type(value).strip() + except UnicodeDecodeError as exc: + raise self.make_error("invalid_utf8") from exc + + # generate new key? + if value.lower() == '-generate-': + return dkim.gen_key() + + # no key? + if not value: + return None + + # remember part of value for ValidationError + bad_key = value + + # strip header and footer, clean whitespace and wrap to 64 characters + try: + if value.startswith('-----BEGIN '): + end = value.index('-----', 11) + 5 + header = value[:end] + value = value[end:] + else: + header = '-----BEGIN PRIVATE KEY-----' + + if (pos := value.find('-----END ')) >= 0: + end = value.index('-----', pos+9) + 5 + footer = value[pos:end] + value = value[:pos] + else: + footer = '-----END PRIVATE KEY-----' + except ValueError as exc: + raise ValidationError(f'invalid dkim key {bad_key!r}') from exc + + # remove whitespace from key data + value = ''.join(value.split()) + + # remember part of value for ValidationError + bad_key = f'{value[:25]}...{value[-10:]}' if len(value) > 40 else value + + # wrap key according to RFC 7468 + value = ('\n'.join(self._wrap_key(header, value, footer))).encode('ascii') + + # check key validity + try: + crypto.load_privatekey(crypto.FILETYPE_PEM, value) + except crypto.Error as exc: + raise ValidationError(f'invalid dkim key {bad_key!r}') from exc + else: + return value + +class PasswordField(fields.Str): + """ Serialize a hashed password hash by stripping the obsolete {SCHEME} + Deserialize a plain password or hashed password into a hashed password + """ + + _hashes = {'PBKDF2', 'BLF-CRYPT', 'SHA512-CRYPT', 'SHA256-CRYPT', 'MD5-CRYPT', 'CRYPT'} + + def _serialize(self, value, attr, obj, **kwargs): + """ strip obsolete {password-hash} when serializing """ + # strip scheme spec if in database - it's obsolete + if value.startswith('{') and (end := value.find('}', 1)) >= 0: + if value[1:end] in self._hashes: + return value[end+1:] + return value + + def _deserialize(self, value, attr, data, **kwargs): + """ hashes plain password or checks hashed password + also strips obsolete {password-hash} when deserializing + """ + + # when hashing is requested: use model instance to hash plain password + if data.get('hash_password'): + # hash password using model instance + inst = self.metadata['model']() + inst.set_password(value) + value = inst.password + del inst + + # strip scheme spec when specified - it's obsolete + if value.startswith('{') and (end := value.find('}', 1)) >= 0: + if value[1:end] in self._hashes: + value = value[end+1:] + + # check if algorithm is supported + inst = self.metadata['model'](password=value) + try: + # just check against empty string to see if hash is valid + inst.check_password('') + except ValueError as exc: + # ValueError: hash could not be identified + raise ValidationError(f'invalid password hash {value!r}') from exc + del inst + + return value + + +### base schema ### + +class Storage: + """ Storage class to save information in context + """ + + context = {} + + def _bind(self, key, bind): + if bind is True: + return (self.__class__, key) + if isinstance(bind, str): + return (get_schema(self.recall(bind).__class__), key) + return (bind, key) + + def store(self, key, value, bind=None): + """ store value under key """ + self.context.setdefault('_track', {})[self._bind(key, bind)]= value + + def recall(self, key, bind=None): + """ recall value from key """ + return self.context['_track'][self._bind(key, bind)] + +class BaseOpts(SQLAlchemyAutoSchemaOpts): + """ Option class with sqla session + """ + def __init__(self, meta, ordered=False): + if not hasattr(meta, 'sqla_session'): + meta.sqla_session = models.db.session + if not hasattr(meta, 'sibling'): + meta.sibling = False + super(BaseOpts, self).__init__(meta, ordered=ordered) + +class BaseSchema(ma.SQLAlchemyAutoSchema, Storage): + """ Marshmallow base schema with custom exclude logic + and option to hide sqla defaults + """ + + OPTIONS_CLASS = BaseOpts + + class Meta: + """ Schema config """ + include_by_context = {} + exclude_by_value = {} + hide_by_context = {} + order = [] + sibling = False + + def __init__(self, *args, **kwargs): + + # prepare only to auto-include explicitly specified attributes + only = set(kwargs.get('only') or []) + + # get context + context = kwargs.get('context', {}) + flags = {key for key, value in context.items() if value is True} + + # compile excludes + exclude = set(kwargs.get('exclude', [])) + + # always exclude + exclude.update({'created_at', 'updated_at'} - only) + + # add include_by_context + if context is not None: + for need, what in getattr(self.Meta, 'include_by_context', {}).items(): + if not flags & set(need): + exclude |= what - only + + # update excludes + kwargs['exclude'] = exclude + + # init SQLAlchemyAutoSchema + super().__init__(*args, **kwargs) + + # exclude_by_value + self._exclude_by_value = { + key: values for key, values in getattr(self.Meta, 'exclude_by_value', {}).items() + if key not in only + } + + # exclude default values + if not context.get('full'): + for column in self.opts.model.__table__.columns: + if column.name not in exclude and column.name not in only: + self._exclude_by_value.setdefault(column.name, []).append( + None if column.default is None else column.default.arg + ) + + # hide by context + self._hide_by_context = set() + if context is not None: + for need, what in getattr(self.Meta, 'hide_by_context', {}).items(): + if not flags & set(need): + self._hide_by_context |= what - only + + # remember primary keys + self._primary = str(self.opts.model.__table__.primary_key.columns.values()[0].name) + + # determine attribute order + if hasattr(self.Meta, 'order'): + # use user-defined order + order = self.Meta.order + else: + # default order is: primary_key + other keys alphabetically + order = list(sorted(self.fields.keys())) + if self._primary in order: + order.remove(self._primary) + order.insert(0, self._primary) + + # order fieldlists + for fieldlist in (self.fields, self.load_fields, self.dump_fields): + for field in order: + if field in fieldlist: + fieldlist[field] = fieldlist.pop(field) + + # move post_load hook "_add_instance" to the end (after load_instance mixin) + hooks = self._hooks[('post_load', False)] + hooks.remove('_add_instance') + hooks.append('_add_instance') + + def hide(self, data): + """ helper method to hide input data for logging """ + # always returns a copy of data + return { + key: HIDDEN if key in self._hide_by_context else deepcopy(value) + for key, value in data.items() + } + + def _call_and_store(self, *args, **kwargs): + """ track current parent field for pruning """ + self.store('field', kwargs['field_name'], True) + return super()._call_and_store(*args, **kwargs) + + # this is only needed to work around the declared attr "email" primary key in model + def get_instance(self, data): + """ lookup item by defined primary key instead of key(s) from model """ + if self.transient: + return None + if keys := getattr(self.Meta, 'primary_keys', None): + filters = {key: data.get(key) for key in keys} + if None not in filters.values(): + res= self.session.query(self.opts.model).filter_by(**filters).first() + return res + res= super().get_instance(data) + return res + + @pre_load(pass_many=True) + def _patch_many(self, items, many, **kwargs): # pylint: disable=unused-argument + """ - flush sqla session before serializing a section when requested + (make sure all objects that could be referred to later are created) + - when in update mode: patch input data before deserialization + - handle "prune" and "delete" items + - replace values in keys starting with '-' with default + """ + + # flush sqla session + if not self.Meta.sibling: + self.opts.sqla_session.flush() + + # stop early when not updating + if not self.context.get('update'): + return items + + # patch "delete", "prune" and "default" + want_prune = [] + def patch(count, data): + + # don't allow __delete__ coming from input + if '__delete__' in data: + raise ValidationError('Unknown field.', f'{count}.__delete__') + + # fail when hash_password is specified without password + if 'hash_password' in data and not 'password' in data: + raise ValidationError( + 'Nothing to hash. Field "password" is missing.', + field_name = f'{count}.hash_password', + ) + + # handle "prune list" and "delete item" (-pkey: none and -pkey: id) + for key in data: + if key.startswith('-'): + if key[1:] == self._primary: + # delete or prune + if data[key] is None: + # prune + want_prune.append(True) + return None + # mark item for deletion + return {key[1:]: data[key], '__delete__': count} + + # handle "set to default value" (-key: none) + def set_default(key, value): + if not key.startswith('-'): + return (key, value) + key = key[1:] + if not key in self.opts.model.__table__.columns: + return (key, None) + if value is not None: + raise ValidationError( + 'Value must be "null" when resetting to default.', + f'{count}.{key}' + ) + value = self.opts.model.__table__.columns[key].default + if value is None: + raise ValidationError( + 'Field has no default value.', + f'{count}.{key}' + ) + return (key, value.arg) + + return dict(set_default(key, value) for key, value in data.items()) + + # convert items to "delete" and filter "prune" item + items = [ + item for item in [ + patch(count, item) for count, item in enumerate(items) + ] if item + ] + + # remember if prune was requested for _prune_items@post_load + self.store('prune', bool(want_prune), True) + + # remember original items to stabilize password-changes in _add_instance@post_load + self.store('original', items, True) + + return items + + @pre_load + def _patch_item(self, data, many, **kwargs): # pylint: disable=unused-argument + """ - call callback function to track import + - stabilize import of items with auto-increment primary key + - delete items + - delete/prune list attributes + - add missing required attributes + """ + + # callback + if callback := self.context.get('callback'): + callback(self, data) + + # stop early when not updating + if not self.opts.load_instance or not self.context.get('update'): + return data + + # stabilize import of auto-increment primary keys (not required), + # by matching import data to existing items and setting primary key + if not self._primary in data: + for item in getattr(self.recall('parent'), self.recall('field', 'parent')): + existing = self.dump(item, many=False) + this = existing.pop(self._primary) + if data == existing: + instance = item + data[self._primary] = this + break + + # try to load instance + instance = self.instance or self.get_instance(data) + if instance is None: + + if '__delete__' in data: + # deletion of non-existent item requested + raise ValidationError( + f'Item to delete not found: {data[self._primary]!r}.', + field_name = f'{data["__delete__"]}.{self._primary}', + ) + + else: + + if self.context.get('update'): + # remember instance as parent for pruning siblings + if not self.Meta.sibling: + self.store('parent', instance) + # delete instance from session when marked + if '__delete__' in data: + self.opts.sqla_session.delete(instance) + # delete item from lists or prune lists + # currently: domain.alternatives, user.forward_destination, + # user.manager_of, aliases.destination + for key, value in data.items(): + if not isinstance(self.fields.get(key), ( + RelatedList, CommaSeparatedListField, fields.Raw) + ) or not isinstance(value, list): + continue + # deduplicate new value + new_value = set(value) + # handle list pruning + if '-prune-' in value: + value.remove('-prune-') + new_value.remove('-prune-') + else: + for old in getattr(instance, key): + # using str() is okay for now (see above) + new_value.add(str(old)) + # handle item deletion + for item in value: + if item.startswith('-'): + new_value.remove(item) + try: + new_value.remove(item[1:]) + except KeyError as exc: + raise ValidationError( + f'Item to delete not found: {item[1:]!r}.', + field_name=f'?.{key}', + ) from exc + # sort list of new values + data[key] = sorted(new_value) + # log backref modification not catched by modify hook + if isinstance(self.fields[key], RelatedList): + if callback := self.context.get('callback'): + before = {str(v) for v in getattr(instance, key)} + after = set(data[key]) + if before != after: + callback(self, instance, { + 'key': key, + 'target': str(instance), + 'before': before, + 'after': after, + }) + + # add attributes required for validation from db + for attr_name, field_obj in self.load_fields.items(): + if field_obj.required and attr_name not in data: + data[attr_name] = getattr(instance, attr_name) + + return data + + @post_load(pass_many=True) + def _prune_items(self, items, many, **kwargs): # pylint: disable=unused-argument + """ handle list pruning """ + + # stop early when not updating + if not self.context.get('update'): + return items + + # get prune flag from _patch_many@pre_load + want_prune = self.recall('prune', True) + + # prune: determine if existing items in db need to be added or marked for deletion + add_items = False + del_items = False + if self.Meta.sibling: + # parent prunes automatically + if not want_prune: + # no prune requested => add old items + add_items = True + else: + # parent does not prune automatically + if want_prune: + # prune requested => mark old items for deletion + del_items = True + + if add_items or del_items: + existing = {item[self._primary] for item in items if self._primary in item} + for item in getattr(self.recall('parent'), self.recall('field', 'parent')): + key = getattr(item, self._primary) + if key not in existing: + if add_items: + items.append({self._primary: key}) + else: + items.append({self._primary: key, '__delete__': '?'}) + + return items + + @post_load + def _add_instance(self, item, many, **kwargs): # pylint: disable=unused-argument + """ - undo password change in existing instances when plain password did not change + - add new instances to sqla session + """ + + if not item in self.opts.sqla_session: + self.opts.sqla_session.add(item) + return item + + # stop early when not updating or item has no password attribute + if not self.context.get('update') or not hasattr(item, 'password'): + return item + + # did we hash a new plaintext password? + original = None + pkey = getattr(item, self._primary) + for data in self.recall('original', True): + if 'hash_password' in data and data.get(self._primary) == pkey: + original = data['password'] + break + if original is None: + # password was hashed by us + return item + + # reset hash if plain password matches hash from db + if attr := getattr(sqlalchemy.inspect(item).attrs, 'password', None): + if attr.history.has_changes() and attr.history.deleted: + try: + # reset password hash + inst = type(item)(password=attr.history.deleted[-1]) + if inst.check_password(original): + item.password = inst.password + except ValueError: + # hash in db is invalid + pass + else: + del inst + + return item + + @post_dump + def _hide_values(self, data, many, **kwargs): # pylint: disable=unused-argument + """ hide secrets """ + + # stop early when not excluding/hiding + if not self._exclude_by_value and not self._hide_by_context: + return data + + # exclude or hide values + full = self.context.get('full') + return type(data)( + (key, HIDDEN if key in self._hide_by_context else value) + for key, value in data.items() + if full or key not in self._exclude_by_value or value not in self._exclude_by_value[key] + ) + + # this field is used to mark items for deletion + mark_delete = fields.Boolean(data_key='__delete__', load_only=True) + + # TODO: this can be removed when comment is not nullable in model + comment = LazyStringField() + + +### schema definitions ### + +@mapped +class DomainSchema(BaseSchema): + """ Marshmallow schema for Domain model """ + class Meta: + """ Schema config """ + model = models.Domain + load_instance = True + include_relationships = True + exclude = ['users', 'managers', 'aliases'] + + include_by_context = { + ('dns',): {'dkim_publickey', 'dns_mx', 'dns_spf', 'dns_dkim', 'dns_dmarc'}, + } + hide_by_context = { + ('secrets',): {'dkim_key'}, + } + exclude_by_value = { + 'alternatives': [[]], + 'dkim_key': [None], + 'dkim_publickey': [None], + 'dns_mx': [None], + 'dns_spf': [None], + 'dns_dkim': [None], + 'dns_dmarc': [None], + } + + dkim_key = DkimKeyField(allow_none=True) + dkim_publickey = fields.String(dump_only=True) + dns_mx = fields.String(dump_only=True) + dns_spf = fields.String(dump_only=True) + dns_dkim = fields.String(dump_only=True) + dns_dmarc = fields.String(dump_only=True) + + +@mapped +class TokenSchema(BaseSchema): + """ Marshmallow schema for Token model """ + class Meta: + """ Schema config """ + model = models.Token + load_instance = True + + sibling = True + + password = PasswordField(required=True, metadata={'model': models.User}) + hash_password = fields.Boolean(load_only=True, missing=False) + + +@mapped +class FetchSchema(BaseSchema): + """ Marshmallow schema for Fetch model """ + class Meta: + """ Schema config """ + model = models.Fetch + load_instance = True + + sibling = True + include_by_context = { + ('full', 'import'): {'last_check', 'error'}, + } + hide_by_context = { + ('secrets',): {'password'}, + } + + +@mapped +class UserSchema(BaseSchema): + """ Marshmallow schema for User model """ + class Meta: + """ Schema config """ + model = models.User + load_instance = True + include_relationships = True + exclude = ['_email', 'domain', 'localpart', 'domain_name', 'quota_bytes_used'] + + primary_keys = ['email'] + exclude_by_value = { + 'forward_destination': [[]], + 'tokens': [[]], + 'fetches': [[]], + 'manager_of': [[]], + 'reply_enddate': ['2999-12-31'], + 'reply_startdate': ['1900-01-01'], + } + + email = fields.String(required=True) + tokens = fields.Nested(TokenSchema, many=True) + fetches = fields.Nested(FetchSchema, many=True) + + password = PasswordField(required=True, metadata={'model': models.User}) + hash_password = fields.Boolean(load_only=True, missing=False) + + +@mapped +class AliasSchema(BaseSchema): + """ Marshmallow schema for Alias model """ + class Meta: + """ Schema config """ + model = models.Alias + load_instance = True + exclude = ['_email', 'domain', 'localpart', 'domain_name'] + + primary_keys = ['email'] + exclude_by_value = { + 'destination': [[]], + } + + email = fields.String(required=True) + destination = CommaSeparatedListField() + + +@mapped +class ConfigSchema(BaseSchema): + """ Marshmallow schema for Config model """ + class Meta: + """ Schema config """ + model = models.Config + load_instance = True + + +@mapped +class RelaySchema(BaseSchema): + """ Marshmallow schema for Relay model """ + class Meta: + """ Schema config """ + model = models.Relay + load_instance = True + + +@mapped +class MailuSchema(Schema, Storage): + """ Marshmallow schema for complete Mailu config """ + class Meta: + """ Schema config """ + model = models.MailuConfig + render_module = RenderYAML + + order = ['domain', 'user', 'alias', 'relay'] # 'config' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # order fieldlists + for fieldlist in (self.fields, self.load_fields, self.dump_fields): + for field in self.Meta.order: + if field in fieldlist: + fieldlist[field] = fieldlist.pop(field) + + def _call_and_store(self, *args, **kwargs): + """ track current parent and field for pruning """ + self.store('field', kwargs['field_name'], True) + self.store('parent', self.context.get('config')) + return super()._call_and_store(*args, **kwargs) + + @pre_load + def _clear_config(self, data, many, **kwargs): # pylint: disable=unused-argument + """ create config object in context if missing + and clear it if requested + """ + if 'config' not in self.context: + self.context['config'] = models.MailuConfig() + if self.context.get('clear'): + self.context['config'].clear( + models = {field.nested.opts.model for field in self.fields.values()} + ) + return data + + @post_load + def _make_config(self, data, many, **kwargs): # pylint: disable=unused-argument + """ update and return config object """ + config = self.context['config'] + for section in self.Meta.order: + if section in data: + config.update(data[section], section) + + return config + + domain = fields.Nested(DomainSchema, many=True) + user = fields.Nested(UserSchema, many=True) + alias = fields.Nested(AliasSchema, many=True) + relay = fields.Nested(RelaySchema, many=True) +# config = fields.Nested(ConfigSchema, many=True) diff --git a/core/admin/mailu/sso/__init__.py b/core/admin/mailu/sso/__init__.py new file mode 100644 index 00000000..98b5abd0 --- /dev/null +++ b/core/admin/mailu/sso/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +sso = Blueprint('sso', __name__, static_folder=None, template_folder='templates') + +from mailu.sso.views import * diff --git a/core/admin/mailu/sso/forms.py b/core/admin/mailu/sso/forms.py new file mode 100644 index 00000000..c190b8bc --- /dev/null +++ b/core/admin/mailu/sso/forms.py @@ -0,0 +1,11 @@ +from wtforms import validators, fields +from flask_babel import lazy_gettext as _ +import flask_wtf + +class LoginForm(flask_wtf.FlaskForm): + class Meta: + csrf = False + email = fields.StringField(_('E-mail'), [validators.Email(), validators.DataRequired()]) + pw = fields.PasswordField(_('Password'), [validators.DataRequired()]) + submitAdmin = fields.SubmitField(_('Sign in')) + submitWebmail = fields.SubmitField(_('Sign in')) diff --git a/core/admin/mailu/sso/templates/base_sso.html b/core/admin/mailu/sso/templates/base_sso.html new file mode 100644 index 00000000..9dfb25a5 --- /dev/null +++ b/core/admin/mailu/sso/templates/base_sso.html @@ -0,0 +1,86 @@ +{%- import "macros.html" as macros %} +{%- import "bootstrap/utils.html" as utils %} + + + + + + + + Mailu-Admin | {{ config["SITENAME"] }} + + + + +
+ + +
+
+
+
+
+

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

+ {% block subtitle %}{% endblock %} +
+
+ {%- block main_action %}{%- endblock %} +
+
+
+
+
+ {{ utils.flashed_messages(container=False, default_category='success') }} + {%- block content %}{%- endblock %} +
+
+ +
+ + + + diff --git a/core/admin/mailu/sso/templates/form_sso.html b/core/admin/mailu/sso/templates/form_sso.html new file mode 100644 index 00000000..d2451597 --- /dev/null +++ b/core/admin/mailu/sso/templates/form_sso.html @@ -0,0 +1,11 @@ +{%- extends "base_sso.html" %} + +{%- block content %} +{%- call macros.card() %} +
+ {{ macros.form_field(form.email) }} + {{ macros.form_field(form.pw) }} + {{ macros.form_fields(fields, label=False, class="btn btn-default") }} +
+{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/sso/templates/login.html b/core/admin/mailu/sso/templates/login.html new file mode 100644 index 00000000..c727e01e --- /dev/null +++ b/core/admin/mailu/sso/templates/login.html @@ -0,0 +1,5 @@ +{%- extends "form_sso.html" %} + +{%- block title %} +{% trans %}Sign in{% endtrans %} +{%- endblock %} diff --git a/core/admin/mailu/sso/templates/sidebar_sso.html b/core/admin/mailu/sso/templates/sidebar_sso.html new file mode 100644 index 00000000..86db3333 --- /dev/null +++ b/core/admin/mailu/sso/templates/sidebar_sso.html @@ -0,0 +1,55 @@ + diff --git a/core/admin/mailu/sso/views/__init__.py b/core/admin/mailu/sso/views/__init__.py new file mode 100644 index 00000000..7b1830fb --- /dev/null +++ b/core/admin/mailu/sso/views/__init__.py @@ -0,0 +1,3 @@ +__all__ = [ + 'base', 'languages' +] diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py new file mode 100644 index 00000000..390d5bbf --- /dev/null +++ b/core/admin/mailu/sso/views/base.py @@ -0,0 +1,57 @@ +from werkzeug.utils import redirect +from mailu import models, utils +from mailu.sso import sso, forms +from mailu.ui import access + +from flask import current_app as app +import flask +import flask_login + +@sso.route('/login', methods=['GET', 'POST']) +def login(): + client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) + form = forms.LoginForm() + form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin' + form.submitWebmail.label.text = form.submitWebmail.label.text + ' Webmail' + + fields = [] + if str(app.config["ADMIN"]).upper() != "FALSE": + fields.append(form.submitAdmin) + if str(app.config["WEBMAIL"]).upper() != "NONE": + fields.append(form.submitWebmail) + fields = [fields] + + if form.validate_on_submit(): + if form.submitAdmin.data: + destination = app.config['WEB_ADMIN'] + elif form.submitWebmail.data: + destination = app.config['WEB_WEBMAIL'] + device_cookie, device_cookie_username = utils.limiter.parse_device_cookie(flask.request.cookies.get('rate_limit')) + username = form.email.data + if username != device_cookie_username and utils.limiter.should_rate_limit_ip(client_ip): + flask.flash('Too many attempts from your IP (rate-limit)', 'error') + return flask.render_template('login.html', form=form) + if utils.limiter.should_rate_limit_user(username, client_ip, device_cookie, device_cookie_username): + flask.flash('Too many attempts for this user (rate-limit)', 'error') + return flask.render_template('login.html', form=form) + user = models.User.login(username, form.pw.data) + if user: + flask.session.regenerate() + flask_login.login_user(user) + response = flask.redirect(destination) + response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True) + flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.') + return response + else: + utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip) + flask.current_app.logger.warn(f'Login failed for {username} from {client_ip}.') + flask.flash('Wrong e-mail or password', 'error') + return flask.render_template('login.html', form=form, fields=fields) + +@sso.route('/logout', methods=['GET']) +@access.authenticated +def logout(): + flask_login.logout_user() + flask.session.destroy() + return flask.redirect(flask.url_for('.login')) + diff --git a/core/admin/mailu/sso/views/languages.py b/core/admin/mailu/sso/views/languages.py new file mode 100644 index 00000000..66c09b1f --- /dev/null +++ b/core/admin/mailu/sso/views/languages.py @@ -0,0 +1,7 @@ +from mailu.sso import sso +import flask + +@sso.route('/language/', methods=['POST']) +def set_language(language=None): + flask.session['language'] = language + return flask.Response(status=200) diff --git a/core/admin/mailu/translations/ca/LC_MESSAGES/messages.po b/core/admin/mailu/translations/ca/LC_MESSAGES/messages.po index f63b7083..880709f1 100644 --- a/core/admin/mailu/translations/ca/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/ca/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2018-04-22 12:10+0200\n" -"PO-Revision-Date: 2020-04-26 13:09+0000\n" +"PO-Revision-Date: 2021-03-04 18:46+0000\n" "Last-Translator: Jaume Barber \n" "Language-Team: Catalan \n" @@ -139,7 +139,7 @@ msgstr "Nom per mostrar" #: mailu/ui/forms.py:98 msgid "Enable spam filter" -msgstr "Activeu filtre d'spam" +msgstr "Activeu filtre spam" #: mailu/ui/forms.py:99 msgid "Spam filter tolerance" @@ -204,7 +204,8 @@ msgstr "Àlies" #: mailu/ui/forms.py:138 msgid "Use SQL LIKE Syntax (e.g. for catch-all aliases)" -msgstr "Feu servir sintaxi tipus SQL (ex. per a agafar tots els àlies)" +msgstr "" +"Feu servir sintaxi tipus SQL (ex. per seleccionar tots els àlies catch-all)" #: mailu/ui/forms.py:145 msgid "Admin email" @@ -246,11 +247,11 @@ msgstr "Mantén els correus al servidor" #: mailu/ui/forms.py:168 msgid "Announcement subject" -msgstr "Tema de l'avís" +msgstr "Tema de la notificació" #: mailu/ui/forms.py:170 msgid "Announcement body" -msgstr "Missatge de l'avís" +msgstr "Missatge de la notificació" #: mailu/ui/forms.py:172 msgid "Send" @@ -258,7 +259,7 @@ msgstr "Envia" #: mailu/ui/templates/announcement.html:4 msgid "Public announcement" -msgstr "Avís públic" +msgstr "Notificació pública" #: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:82 msgid "Client setup" @@ -304,7 +305,7 @@ msgstr "Resposta automàtica" #: mailu/ui/templates/fetch/list.html:4 mailu/ui/templates/sidebar.html:26 #: mailu/ui/templates/user/list.html:36 msgid "Fetched accounts" -msgstr "Comptes trobats" +msgstr "Comptes vinculats" #: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4 msgid "Authentication tokens" @@ -316,7 +317,7 @@ msgstr "Administració" #: mailu/ui/templates/sidebar.html:44 msgid "Announcement" -msgstr "Avís" +msgstr "Notificació" #: mailu/ui/templates/sidebar.html:49 msgid "Administrators" @@ -324,7 +325,7 @@ msgstr "Administradors" #: mailu/ui/templates/sidebar.html:54 msgid "Relayed domains" -msgstr "Dominis tramesos" +msgstr "Dominis traspassats" #: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15 msgid "Antispam" @@ -546,18 +547,19 @@ msgid "" " expires." msgstr "" "Si no sabeu configurar un registre MX a la zona DNS,\n" -"contacteu el vostre proveïdor o administrador de DNS. Per favor, espereu \n" +"contacteu amb el vostre proveïdor o administrador de DNS. Per favor, espereu " +"\n" "uns quants minuts despres d'ajustar el registre MX perquè la " "caixet \n" "del servidor local expire." #: mailu/ui/templates/fetch/create.html:4 msgid "Add a fetched account" -msgstr "Afegiu un compte (fetched)" +msgstr "Afegiu un compte extern" #: mailu/ui/templates/fetch/edit.html:4 msgid "Update a fetched account" -msgstr "Actualitzeu un compte (fetched)" +msgstr "Actualitzeu compte extern" #: mailu/ui/templates/fetch/list.html:12 msgid "Add an account" @@ -605,11 +607,11 @@ msgstr "Editeu domini llegat (relayed)" #: mailu/ui/templates/relay/list.html:4 msgid "Relayed domain list" -msgstr "Llista de dominis llegats (relayed)" +msgstr "Llista de dominis traspassats" #: mailu/ui/templates/relay/list.html:9 msgid "New relayed domain" -msgstr "Nou domini llegat (relayed)" +msgstr "Nou domini traspassat" #: mailu/ui/templates/token/create.html:4 msgid "Create an authentication token" @@ -653,7 +655,7 @@ msgstr "Ajustos d'usuari" #: mailu/ui/templates/user/list.html:21 msgid "Features" -msgstr "Funcions" +msgstr "Característiques" #: mailu/ui/templates/user/password.html:4 msgid "Password update" @@ -669,11 +671,11 @@ msgstr "Auto-reenviament" #: mailu/ui/templates/user/signup_domain.html:8 msgid "pick a domain for the new account" -msgstr "tria un domini per al compte nou" +msgstr "trieu un domini per al compte nou" #: mailu/ui/templates/user/signup_domain.html:14 msgid "Domain" -msgstr "Domini" +msgstr "Nom de domini" #: mailu/ui/templates/user/signup_domain.html:15 msgid "Available slots" diff --git a/core/admin/mailu/translations/de/LC_MESSAGES/messages.po b/core/admin/mailu/translations/de/LC_MESSAGES/messages.po index 941c22ef..4ae71561 100644 --- a/core/admin/mailu/translations/de/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/de/LC_MESSAGES/messages.po @@ -1,11 +1,16 @@ msgid "" msgstr "" +"Project-Id-Version: Mailu\n" +"PO-Revision-Date: 2021-03-04 18:46+0000\n" +"Last-Translator: Anonymous \n" +"Language-Team: German \n" +"Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: POEditor.com\n" -"Project-Id-Version: Mailu\n" -"Language: de\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.0.1\n" #: mailu/ui/forms.py:32 msgid "Invalid email address." @@ -64,7 +69,7 @@ msgstr "Passwort bestätigen" #: mailu/ui/forms.py:80 mailu/ui/templates/user/list.html:22 #: mailu/ui/templates/user/signup_domain.html:16 msgid "Quota" -msgstr "Quota" +msgstr "Kontingent" #: mailu/ui/forms.py:81 msgid "Allow IMAP access" @@ -699,4 +704,3 @@ msgstr "Domain" #: mailu/ui/templates/user/signup_domain.html:15 msgid "Available slots" msgstr "Verfügbare Plätze" - diff --git a/core/admin/mailu/translations/en/LC_MESSAGES/messages.po b/core/admin/mailu/translations/en/LC_MESSAGES/messages.po index 2ada20b1..4db1dbf1 100644 --- a/core/admin/mailu/translations/en/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/en/LC_MESSAGES/messages.po @@ -8,8 +8,8 @@ msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2018-04-22 12:10+0200\n" -"PO-Revision-Date: 2020-03-11 23:03+0000\n" -"Last-Translator: Jae Beojkkoch \n" +"PO-Revision-Date: 2021-03-04 18:46+0000\n" +"Last-Translator: Jaume Barber \n" "Language-Team: English \n" "Language: en\n" @@ -17,7 +17,7 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.11.2\n" +"X-Generator: Weblate 4.0.1\n" "Generated-By: Babel 2.5.3\n" #: mailu/ui/forms.py:32 @@ -30,13 +30,13 @@ msgstr "Confirm" #: mailu/ui/forms.py:40 mailu/ui/forms.py:77 msgid "E-mail" -msgstr "" +msgstr "E-mail" #: mailu/ui/forms.py:41 mailu/ui/forms.py:78 mailu/ui/forms.py:90 #: mailu/ui/forms.py:109 mailu/ui/forms.py:162 #: mailu/ui/templates/client.html:32 mailu/ui/templates/client.html:59 msgid "Password" -msgstr "" +msgstr "Password" #: mailu/ui/forms.py:42 mailu/ui/templates/login.html:4 #: mailu/ui/templates/sidebar.html:111 @@ -51,7 +51,7 @@ msgstr "" #: mailu/ui/forms.py:47 msgid "Maximum user count" -msgstr "" +msgstr "Maximum user count" #: mailu/ui/forms.py:48 msgid "Maximum alias count" @@ -59,11 +59,11 @@ msgstr "" #: mailu/ui/forms.py:49 msgid "Maximum user quota" -msgstr "" +msgstr "Maximum user quota" #: mailu/ui/forms.py:50 msgid "Enable sign-up" -msgstr "" +msgstr "Enable sign-up" #: mailu/ui/forms.py:51 mailu/ui/forms.py:72 mailu/ui/forms.py:83 #: mailu/ui/forms.py:128 mailu/ui/forms.py:140 @@ -71,28 +71,28 @@ msgstr "" #: mailu/ui/templates/relay/list.html:19 mailu/ui/templates/token/list.html:19 #: mailu/ui/templates/user/list.html:23 msgid "Comment" -msgstr "" +msgstr "Comment" #: mailu/ui/forms.py:52 mailu/ui/forms.py:61 mailu/ui/forms.py:66 #: mailu/ui/forms.py:73 mailu/ui/forms.py:132 mailu/ui/forms.py:141 msgid "Create" -msgstr "" +msgstr "Create" #: mailu/ui/forms.py:57 msgid "Initial admin" -msgstr "" +msgstr "Initial admin" #: mailu/ui/forms.py:58 msgid "Admin password" -msgstr "" +msgstr "Admin password" #: mailu/ui/forms.py:59 mailu/ui/forms.py:79 mailu/ui/forms.py:91 msgid "Confirm password" -msgstr "" +msgstr "Confirm password" #: mailu/ui/forms.py:65 msgid "Alternative name" -msgstr "" +msgstr "Alternative name" #: mailu/ui/forms.py:70 msgid "Relayed domain name" @@ -105,23 +105,23 @@ msgstr "" #: mailu/ui/forms.py:80 mailu/ui/templates/user/list.html:22 #: mailu/ui/templates/user/signup_domain.html:16 msgid "Quota" -msgstr "" +msgstr "Quota" #: mailu/ui/forms.py:81 msgid "Allow IMAP access" -msgstr "" +msgstr "Allow IMAP access" #: mailu/ui/forms.py:82 msgid "Allow POP3 access" -msgstr "" +msgstr "Allow POP3 access" #: mailu/ui/forms.py:84 msgid "Enabled" -msgstr "" +msgstr "Enabled" #: mailu/ui/forms.py:85 msgid "Save" -msgstr "" +msgstr "Save" #: mailu/ui/forms.py:89 msgid "Email address" @@ -131,7 +131,7 @@ msgstr "" #: mailu/ui/templates/user/signup.html:4 #: mailu/ui/templates/user/signup_domain.html:4 msgid "Sign up" -msgstr "" +msgstr "Sign up" #: mailu/ui/forms.py:97 msgid "Displayed name" @@ -139,15 +139,15 @@ msgstr "" #: mailu/ui/forms.py:98 msgid "Enable spam filter" -msgstr "" +msgstr "Enable spam filter" #: mailu/ui/forms.py:99 msgid "Spam filter tolerance" -msgstr "" +msgstr "Spam filter tolerance" #: mailu/ui/forms.py:100 msgid "Enable forwarding" -msgstr "" +msgstr "Enable forwarding" #: mailu/ui/forms.py:101 msgid "Keep a copy of the emails" @@ -160,7 +160,7 @@ msgstr "" #: mailu/ui/forms.py:105 msgid "Save settings" -msgstr "" +msgstr "Save settings" #: mailu/ui/forms.py:110 msgid "Password check" @@ -184,11 +184,11 @@ msgstr "" #: mailu/ui/forms.py:119 msgid "End of vacation" -msgstr "" +msgstr "End of vacation" #: mailu/ui/forms.py:120 msgid "Update" -msgstr "" +msgstr "Update" #: mailu/ui/forms.py:125 msgid "Your token (write it down, as it will never be displayed again)" @@ -196,11 +196,11 @@ msgstr "" #: mailu/ui/forms.py:130 mailu/ui/templates/token/list.html:20 msgid "Authorized IP" -msgstr "" +msgstr "Authorized IP" #: mailu/ui/forms.py:136 msgid "Alias" -msgstr "" +msgstr "Alias" #: mailu/ui/forms.py:138 msgid "Use SQL LIKE Syntax (e.g. for catch-all aliases)" @@ -229,7 +229,7 @@ msgstr "" #: mailu/ui/forms.py:159 mailu/ui/templates/client.html:20 #: mailu/ui/templates/client.html:47 msgid "TCP port" -msgstr "" +msgstr "TCP port" #: mailu/ui/forms.py:160 msgid "Enable TLS" @@ -283,7 +283,7 @@ msgstr "" #: mailu/ui/templates/docker-error.html:4 msgid "Docker error" -msgstr "" +msgstr "Docker error" #: mailu/ui/templates/docker-error.html:12 msgid "An error occurred while talking to the Docker server." @@ -328,11 +328,11 @@ msgstr "" #: mailu/ui/templates/sidebar.html:54 msgid "Relayed domains" -msgstr "" +msgstr "Relayed domains" #: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15 msgid "Antispam" -msgstr "" +msgstr "Antispam" #: mailu/ui/templates/sidebar.html:66 msgid "Mail domains" @@ -593,7 +593,7 @@ msgstr "" #: mailu/ui/templates/relay/create.html:4 msgid "New relay domain" -msgstr "" +msgstr "New relay domain" #: mailu/ui/templates/relay/edit.html:4 msgid "Edit relayd domain" @@ -601,11 +601,11 @@ msgstr "" #: mailu/ui/templates/relay/list.html:4 msgid "Relayed domain list" -msgstr "" +msgstr "Relayed domain list" #: mailu/ui/templates/relay/list.html:9 msgid "New relayed domain" -msgstr "" +msgstr "New relayed domain" #: mailu/ui/templates/token/create.html:4 msgid "Create an authentication token" @@ -669,7 +669,7 @@ msgstr "" #: mailu/ui/templates/user/signup_domain.html:14 msgid "Domain" -msgstr "" +msgstr "Domain" #: mailu/ui/templates/user/signup_domain.html:15 msgid "Available slots" diff --git a/core/admin/mailu/translations/es/LC_MESSAGES/messages.po b/core/admin/mailu/translations/es/LC_MESSAGES/messages.po index 94b39439..c70ed6f5 100644 --- a/core/admin/mailu/translations/es/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/es/LC_MESSAGES/messages.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Mailu\n" -"PO-Revision-Date: 2020-03-11 23:03+0000\n" +"PO-Revision-Date: 2021-03-04 18:46+0000\n" "Last-Translator: Jaume Barber \n" "Language-Team: Spanish \n" @@ -10,7 +10,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.11.2\n" +"X-Generator: Weblate 4.0.1\n" #: mailu/ui/forms.py:32 msgid "Invalid email address." @@ -425,7 +425,7 @@ msgstr "Añadir una cuenta" #: mailu/ui/templates/fetch/list.html:19 msgid "Endpoint" -msgstr "Punto final" +msgstr "Endpoint" #: mailu/ui/templates/fetch/list.html:22 msgid "Last check" @@ -437,7 +437,7 @@ msgstr "Añadir un gestor" #: mailu/ui/templates/manager/list.html:4 msgid "Manager list" -msgstr "Gestor de lista" +msgstr "Lista de gestores" #: mailu/ui/templates/manager/list.html:12 msgid "Add manager" @@ -578,7 +578,7 @@ msgstr "Lista de dominios externos (relayed)" #: mailu/ui/templates/relay/list.html:9 msgid "New relayed domain" -msgstr "Nuevo dominio externo (relayed)" +msgstr "Editar dominio externo (relay)" #: mailu/ui/forms.py:125 msgid "Your token (write it down, as it will never be displayed again)" diff --git a/core/admin/mailu/translations/eu/LC_MESSAGES/messages.po b/core/admin/mailu/translations/eu/LC_MESSAGES/messages.po new file mode 100644 index 00000000..6ca737a3 --- /dev/null +++ b/core/admin/mailu/translations/eu/LC_MESSAGES/messages.po @@ -0,0 +1,672 @@ +# Translations template for PROJECT. +# Copyright (C) 2018 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2018. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2018-04-22 12:10+0200\n" +"PO-Revision-Date: 2021-03-04 18:46+0000\n" +"Last-Translator: Jaume Barber \n" +"Language-Team: Basque \n" +"Language: eu\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.0.1\n" +"Generated-By: Babel 2.5.3\n" + +#: mailu/ui/forms.py:32 +msgid "Invalid email address." +msgstr "baliogabeko helbide elektronikoa." + +#: mailu/ui/forms.py:36 +msgid "Confirm" +msgstr "Ados" + +#: mailu/ui/forms.py:40 mailu/ui/forms.py:77 +msgid "E-mail" +msgstr "E-mail" + +#: mailu/ui/forms.py:41 mailu/ui/forms.py:78 mailu/ui/forms.py:90 +#: mailu/ui/forms.py:109 mailu/ui/forms.py:162 +#: mailu/ui/templates/client.html:32 mailu/ui/templates/client.html:59 +msgid "Password" +msgstr "Pasahitza" + +#: mailu/ui/forms.py:42 mailu/ui/templates/login.html:4 +#: mailu/ui/templates/sidebar.html:111 +msgid "Sign in" +msgstr "" + +#: mailu/ui/forms.py:46 mailu/ui/forms.py:56 +#: mailu/ui/templates/domain/details.html:27 +#: mailu/ui/templates/domain/list.html:18 mailu/ui/templates/relay/list.html:17 +msgid "Domain name" +msgstr "" + +#: mailu/ui/forms.py:47 +msgid "Maximum user count" +msgstr "Erabiltzaileen gehieneko kopurua" + +#: mailu/ui/forms.py:48 +msgid "Maximum alias count" +msgstr "" + +#: mailu/ui/forms.py:49 +msgid "Maximum user quota" +msgstr "Erabiltzaile bakoitzeko gehieneko espazioa" + +#: mailu/ui/forms.py:50 +msgid "Enable sign-up" +msgstr "Gaitu erregistroa" + +#: mailu/ui/forms.py:51 mailu/ui/forms.py:72 mailu/ui/forms.py:83 +#: mailu/ui/forms.py:128 mailu/ui/forms.py:140 +#: mailu/ui/templates/alias/list.html:21 mailu/ui/templates/domain/list.html:21 +#: mailu/ui/templates/relay/list.html:19 mailu/ui/templates/token/list.html:19 +#: mailu/ui/templates/user/list.html:23 +msgid "Comment" +msgstr "Iruzkindua" + +#: mailu/ui/forms.py:52 mailu/ui/forms.py:61 mailu/ui/forms.py:66 +#: mailu/ui/forms.py:73 mailu/ui/forms.py:132 mailu/ui/forms.py:141 +msgid "Create" +msgstr "Sortu" + +#: mailu/ui/forms.py:57 +msgid "Initial admin" +msgstr "Administratzailea" + +#: mailu/ui/forms.py:58 +msgid "Admin password" +msgstr "Administratzaileko pasahitza" + +#: mailu/ui/forms.py:59 mailu/ui/forms.py:79 mailu/ui/forms.py:91 +msgid "Confirm password" +msgstr "Berretsi pasahitza" + +#: mailu/ui/forms.py:65 +msgid "Alternative name" +msgstr "Izen alternatiboa" + +#: mailu/ui/forms.py:70 +msgid "Relayed domain name" +msgstr "Igorritako domeinu izena" + +#: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18 +msgid "Remote host" +msgstr "Urruneko ostalaria" + +#: mailu/ui/forms.py:80 mailu/ui/templates/user/list.html:22 +#: mailu/ui/templates/user/signup_domain.html:16 +msgid "Quota" +msgstr "Espazioa" + +#: mailu/ui/forms.py:81 +msgid "Allow IMAP access" +msgstr "Baimendu IMAP sarbidea" + +#: mailu/ui/forms.py:82 +msgid "Allow POP3 access" +msgstr "Baimendu POP3 sarbidea" + +#: mailu/ui/forms.py:84 +msgid "Enabled" +msgstr "Gaituta" + +#: mailu/ui/forms.py:85 +msgid "Save" +msgstr "Gorde" + +#: mailu/ui/forms.py:89 +msgid "Email address" +msgstr "" + +#: mailu/ui/forms.py:93 mailu/ui/templates/sidebar.html:117 +#: mailu/ui/templates/user/signup.html:4 +#: mailu/ui/templates/user/signup_domain.html:4 +msgid "Sign up" +msgstr "Erregistratu" + +#: mailu/ui/forms.py:97 +msgid "Displayed name" +msgstr "" + +#: mailu/ui/forms.py:98 +msgid "Enable spam filter" +msgstr "Gaitu spam iragazkia" + +#: mailu/ui/forms.py:99 +msgid "Spam filter tolerance" +msgstr "Spam iragazkiaren tolerantzia" + +#: mailu/ui/forms.py:100 +msgid "Enable forwarding" +msgstr "Gaitu birbidaltzea" + +#: mailu/ui/forms.py:101 +msgid "Keep a copy of the emails" +msgstr "" + +#: mailu/ui/forms.py:103 mailu/ui/forms.py:139 +#: mailu/ui/templates/alias/list.html:20 +msgid "Destination" +msgstr "" + +#: mailu/ui/forms.py:105 +msgid "Save settings" +msgstr "Gorde ezarpenak" + +#: mailu/ui/forms.py:110 +msgid "Password check" +msgstr "" + +#: mailu/ui/forms.py:111 mailu/ui/templates/sidebar.html:16 +msgid "Update password" +msgstr "" + +#: mailu/ui/forms.py:115 +msgid "Enable automatic reply" +msgstr "" + +#: mailu/ui/forms.py:116 +msgid "Reply subject" +msgstr "" + +#: mailu/ui/forms.py:117 +msgid "Reply body" +msgstr "" + +#: mailu/ui/forms.py:119 +msgid "End of vacation" +msgstr "Oporren amaiera" + +#: mailu/ui/forms.py:120 +msgid "Update" +msgstr "Eguneratu" + +#: mailu/ui/forms.py:125 +msgid "Your token (write it down, as it will never be displayed again)" +msgstr "" + +#: mailu/ui/forms.py:130 mailu/ui/templates/token/list.html:20 +msgid "Authorized IP" +msgstr "Baimendutako IP" + +#: mailu/ui/forms.py:136 +msgid "Alias" +msgstr "Ezizenza" + +#: mailu/ui/forms.py:138 +msgid "Use SQL LIKE Syntax (e.g. for catch-all aliases)" +msgstr "" + +#: mailu/ui/forms.py:145 +msgid "Admin email" +msgstr "" + +#: mailu/ui/forms.py:146 mailu/ui/forms.py:151 mailu/ui/forms.py:164 +msgid "Submit" +msgstr "" + +#: mailu/ui/forms.py:150 +msgid "Manager email" +msgstr "" + +#: mailu/ui/forms.py:155 +msgid "Protocol" +msgstr "" + +#: mailu/ui/forms.py:158 +msgid "Hostname or IP" +msgstr "" + +#: mailu/ui/forms.py:159 mailu/ui/templates/client.html:20 +#: mailu/ui/templates/client.html:47 +msgid "TCP port" +msgstr "TCP ataka" + +#: mailu/ui/forms.py:160 +msgid "Enable TLS" +msgstr "" + +#: mailu/ui/forms.py:161 mailu/ui/templates/client.html:28 +#: mailu/ui/templates/client.html:55 mailu/ui/templates/fetch/list.html:20 +msgid "Username" +msgstr "" + +#: mailu/ui/forms.py:163 +msgid "Keep emails on the server" +msgstr "" + +#: mailu/ui/forms.py:168 +msgid "Announcement subject" +msgstr "" + +#: mailu/ui/forms.py:170 +msgid "Announcement body" +msgstr "" + +#: mailu/ui/forms.py:172 +msgid "Send" +msgstr "" + +#: mailu/ui/templates/announcement.html:4 +msgid "Public announcement" +msgstr "" + +#: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:82 +msgid "Client setup" +msgstr "" + +#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43 +msgid "Mail protocol" +msgstr "" + +#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51 +msgid "Server name" +msgstr "" + +#: mailu/ui/templates/confirm.html:4 +msgid "Confirm action" +msgstr "" + +#: mailu/ui/templates/confirm.html:13 +#, python-format +msgid "You are about to %(action)s. Please confirm your action." +msgstr "Zu zara %(action)s-etan. Mesedez ekintza honen berretsi." + +#: mailu/ui/templates/docker-error.html:4 +msgid "Docker error" +msgstr "Docker-en errorea" + +#: mailu/ui/templates/docker-error.html:12 +msgid "An error occurred while talking to the Docker server." +msgstr "" + +#: mailu/ui/templates/login.html:8 +msgid "to access the administration tools" +msgstr "" + +#: mailu/ui/templates/sidebar.html:11 mailu/ui/templates/user/list.html:34 +msgid "Settings" +msgstr "" + +#: mailu/ui/templates/sidebar.html:21 mailu/ui/templates/user/list.html:35 +msgid "Auto-reply" +msgstr "" + +#: mailu/ui/templates/fetch/list.html:4 mailu/ui/templates/sidebar.html:26 +#: mailu/ui/templates/user/list.html:36 +msgid "Fetched accounts" +msgstr "" + +#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4 +msgid "Authentication tokens" +msgstr "" + +#: mailu/ui/templates/sidebar.html:35 +msgid "Administration" +msgstr "" + +#: mailu/ui/templates/sidebar.html:44 +msgid "Announcement" +msgstr "" + +#: mailu/ui/templates/sidebar.html:49 +msgid "Administrators" +msgstr "" + +#: mailu/ui/templates/sidebar.html:54 +msgid "Relayed domains" +msgstr "Igorritako domeinuak" + +#: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15 +msgid "Antispam" +msgstr "Antispam" + +#: mailu/ui/templates/sidebar.html:66 +msgid "Mail domains" +msgstr "" + +#: mailu/ui/templates/sidebar.html:72 +msgid "Go to" +msgstr "" + +#: mailu/ui/templates/sidebar.html:76 +msgid "Webmail" +msgstr "" + +#: mailu/ui/templates/sidebar.html:87 +msgid "Website" +msgstr "" + +#: mailu/ui/templates/sidebar.html:92 +msgid "Help" +msgstr "" + +#: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:98 +msgid "Register a domain" +msgstr "" + +#: mailu/ui/templates/sidebar.html:105 +msgid "Sign out" +msgstr "" + +#: mailu/ui/templates/working.html:4 +msgid "We are still working on this feature!" +msgstr "" + +#: mailu/ui/templates/admin/create.html:4 +msgid "Add a global administrator" +msgstr "" + +#: mailu/ui/templates/admin/list.html:4 +msgid "Global administrators" +msgstr "" + +#: mailu/ui/templates/admin/list.html:9 +msgid "Add administrator" +msgstr "" + +#: mailu/ui/templates/admin/list.html:16 mailu/ui/templates/alias/list.html:18 +#: mailu/ui/templates/alternative/list.html:18 +#: mailu/ui/templates/domain/list.html:16 mailu/ui/templates/fetch/list.html:18 +#: mailu/ui/templates/manager/list.html:18 +#: mailu/ui/templates/relay/list.html:16 mailu/ui/templates/token/list.html:18 +#: mailu/ui/templates/user/list.html:18 +msgid "Actions" +msgstr "" + +#: mailu/ui/templates/admin/list.html:17 mailu/ui/templates/alias/list.html:19 +#: mailu/ui/templates/manager/list.html:19 mailu/ui/templates/user/list.html:20 +msgid "Email" +msgstr "" + +#: mailu/ui/templates/admin/list.html:22 mailu/ui/templates/alias/list.html:29 +#: mailu/ui/templates/alternative/list.html:25 +#: mailu/ui/templates/domain/list.html:31 mailu/ui/templates/fetch/list.html:31 +#: mailu/ui/templates/manager/list.html:24 +#: mailu/ui/templates/relay/list.html:27 mailu/ui/templates/token/list.html:26 +#: mailu/ui/templates/user/list.html:31 +msgid "Delete" +msgstr "" + +#: mailu/ui/templates/alias/create.html:4 +msgid "Create alias" +msgstr "" + +#: mailu/ui/templates/alias/edit.html:4 +msgid "Edit alias" +msgstr "" + +#: mailu/ui/templates/alias/list.html:4 +msgid "Alias list" +msgstr "" + +#: mailu/ui/templates/alias/list.html:12 +msgid "Add alias" +msgstr "" + +#: mailu/ui/templates/alias/list.html:22 +#: mailu/ui/templates/alternative/list.html:20 +#: mailu/ui/templates/domain/list.html:22 mailu/ui/templates/fetch/list.html:24 +#: mailu/ui/templates/relay/list.html:20 mailu/ui/templates/token/list.html:21 +#: mailu/ui/templates/user/list.html:24 +msgid "Created" +msgstr "" + +#: mailu/ui/templates/alias/list.html:23 mailu/ui/templates/domain/list.html:23 +#: mailu/ui/templates/fetch/list.html:25 mailu/ui/templates/relay/list.html:21 +#: mailu/ui/templates/user/list.html:25 +msgid "Last edit" +msgstr "" + +#: mailu/ui/templates/alias/list.html:28 mailu/ui/templates/domain/list.html:30 +#: mailu/ui/templates/fetch/list.html:30 mailu/ui/templates/relay/list.html:26 +#: mailu/ui/templates/user/list.html:30 +msgid "Edit" +msgstr "" + +#: mailu/ui/templates/alternative/create.html:4 +msgid "Create alternative domain" +msgstr "" + +#: mailu/ui/templates/alternative/list.html:4 +msgid "Alternative domain list" +msgstr "" + +#: mailu/ui/templates/alternative/list.html:12 +msgid "Add alternative" +msgstr "" + +#: mailu/ui/templates/alternative/list.html:19 +msgid "Name" +msgstr "" + +#: mailu/ui/templates/domain/create.html:4 +#: mailu/ui/templates/domain/list.html:9 +msgid "New domain" +msgstr "" + +#: mailu/ui/templates/domain/details.html:4 +msgid "Domain details" +msgstr "" + +#: mailu/ui/templates/domain/details.html:15 +msgid "Regenerate keys" +msgstr "" + +#: mailu/ui/templates/domain/details.html:17 +msgid "Generate keys" +msgstr "" + +#: mailu/ui/templates/domain/details.html:31 +msgid "DNS MX entry" +msgstr "" + +#: mailu/ui/templates/domain/details.html:35 +msgid "DNS SPF entries" +msgstr "" + +#: mailu/ui/templates/domain/details.html:42 +msgid "DKIM public key" +msgstr "" + +#: mailu/ui/templates/domain/details.html:46 +msgid "DNS DKIM entry" +msgstr "" + +#: mailu/ui/templates/domain/details.html:50 +msgid "DNS DMARC entry" +msgstr "" + +#: mailu/ui/templates/domain/edit.html:4 +msgid "Edit domain" +msgstr "" + +#: mailu/ui/templates/domain/list.html:4 +msgid "Domain list" +msgstr "" + +#: mailu/ui/templates/domain/list.html:17 +msgid "Manage" +msgstr "" + +#: mailu/ui/templates/domain/list.html:19 +msgid "Mailbox count" +msgstr "" + +#: mailu/ui/templates/domain/list.html:20 +msgid "Alias count" +msgstr "" + +#: mailu/ui/templates/domain/list.html:28 +msgid "Details" +msgstr "" + +#: mailu/ui/templates/domain/list.html:35 +msgid "Users" +msgstr "" + +#: mailu/ui/templates/domain/list.html:36 +msgid "Aliases" +msgstr "" + +#: mailu/ui/templates/domain/list.html:37 +msgid "Managers" +msgstr "" + +#: mailu/ui/templates/domain/list.html:39 +msgid "Alternatives" +msgstr "" + +#: mailu/ui/templates/domain/signup.html:13 +msgid "" +"In order to register a new domain, you must first setup the\n" +" domain zone so that the domain MX points to this server" +msgstr "" + +#: mailu/ui/templates/domain/signup.html:18 +msgid "" +"If you do not know how to setup an MX record for your DNS " +"zone,\n" +" please contact your DNS provider or administrator. Also, please wait " +"a\n" +" couple minutes after the MX is set so the local server " +"cache\n" +" expires." +msgstr "" + +#: mailu/ui/templates/fetch/create.html:4 +msgid "Add a fetched account" +msgstr "" + +#: mailu/ui/templates/fetch/edit.html:4 +msgid "Update a fetched account" +msgstr "" + +#: mailu/ui/templates/fetch/list.html:12 +msgid "Add an account" +msgstr "" + +#: mailu/ui/templates/fetch/list.html:19 +msgid "Endpoint" +msgstr "" + +#: mailu/ui/templates/fetch/list.html:21 +msgid "Keep emails" +msgstr "" + +#: mailu/ui/templates/fetch/list.html:22 +msgid "Last check" +msgstr "" + +#: mailu/ui/templates/fetch/list.html:35 +msgid "yes" +msgstr "" + +#: mailu/ui/templates/fetch/list.html:35 +msgid "no" +msgstr "" + +#: mailu/ui/templates/manager/create.html:4 +msgid "Add a manager" +msgstr "" + +#: mailu/ui/templates/manager/list.html:4 +msgid "Manager list" +msgstr "" + +#: mailu/ui/templates/manager/list.html:12 +msgid "Add manager" +msgstr "" + +#: mailu/ui/templates/relay/create.html:4 +msgid "New relay domain" +msgstr "Igorritako domeinu berria" + +#: mailu/ui/templates/relay/edit.html:4 +msgid "Edit relayd domain" +msgstr "Editatu igorritako domeinua" + +#: mailu/ui/templates/relay/list.html:4 +msgid "Relayed domain list" +msgstr "Igorritako domeinuen zerrenda" + +#: mailu/ui/templates/relay/list.html:9 +msgid "New relayed domain" +msgstr "Igorritako domeinu berria" + +#: mailu/ui/templates/token/create.html:4 +msgid "Create an authentication token" +msgstr "" + +#: mailu/ui/templates/token/list.html:12 +msgid "New token" +msgstr "" + +#: mailu/ui/templates/user/create.html:4 +msgid "New user" +msgstr "" + +#: mailu/ui/templates/user/create.html:15 +msgid "General" +msgstr "" + +#: mailu/ui/templates/user/create.html:22 +msgid "Features and quotas" +msgstr "" + +#: mailu/ui/templates/user/edit.html:4 +msgid "Edit user" +msgstr "" + +#: mailu/ui/templates/user/forward.html:4 +msgid "Forward emails" +msgstr "" + +#: mailu/ui/templates/user/list.html:4 +msgid "User list" +msgstr "" + +#: mailu/ui/templates/user/list.html:12 +msgid "Add user" +msgstr "" + +#: mailu/ui/templates/user/list.html:19 mailu/ui/templates/user/settings.html:4 +msgid "User settings" +msgstr "" + +#: mailu/ui/templates/user/list.html:21 +msgid "Features" +msgstr "" + +#: mailu/ui/templates/user/password.html:4 +msgid "Password update" +msgstr "" + +#: mailu/ui/templates/user/reply.html:4 +msgid "Automatic reply" +msgstr "" + +#: mailu/ui/templates/user/settings.html:22 +msgid "Auto-forward" +msgstr "" + +#: mailu/ui/templates/user/signup_domain.html:8 +msgid "pick a domain for the new account" +msgstr "" + +#: mailu/ui/templates/user/signup_domain.html:14 +msgid "Domain" +msgstr "Domeinu izena" + +#: mailu/ui/templates/user/signup_domain.html:15 +msgid "Available slots" +msgstr "" diff --git a/core/admin/mailu/translations/he/LC_MESSAGES/messages.po b/core/admin/mailu/translations/he/LC_MESSAGES/messages.po index 4fe58afc..91fbbcff 100644 --- a/core/admin/mailu/translations/he/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/he/LC_MESSAGES/messages.po @@ -1,29 +1,30 @@ -# Translations template for PROJECT. -# Copyright (C) 2018 ORGANIZATION -# This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2018. +# Translations template for Mailu. +# Copyright (C) 2018 Mailu +# This file is distributed under the same license as the Mailu project. +# Modi Sacks, 2019-2021. +# Yaron Shahrabani , 2021. # msgid "" msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"Project-Id-Version: Mailu 1.5.1\n" +"Report-Msgid-Bugs-To: heb-bugzap@projects.hamakor.org.il \n" "POT-Creation-Date: 2018-04-22 12:10+0200\n" -"PO-Revision-Date: 2019-11-27 22:20+0000\n" -"Last-Translator: Mordi Sacks \n" +"PO-Revision-Date: 2021-07-19 09:04+0300\n" +"Last-Translator: Yaron Shahrabani \n" "Language-Team: Hebrew \n" "Language: he\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" +"Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && " "n % 10 == 0) ? 2 : 3));\n" -"X-Generator: Weblate 3.3\n" +"X-Generator: Poedit 3.0\n" "Generated-By: Babel 2.5.3\n" #: mailu/ui/forms.py:32 msgid "Invalid email address." -msgstr "כתובת דוא\"ל לא חוקית." +msgstr "כתובת דוא״ל שגויה." #: mailu/ui/forms.py:36 msgid "Confirm" @@ -31,7 +32,7 @@ msgstr "אישור" #: mailu/ui/forms.py:40 mailu/ui/forms.py:77 msgid "E-mail" -msgstr "דוא\"ל" +msgstr "דוא״ל" #: mailu/ui/forms.py:41 mailu/ui/forms.py:78 mailu/ui/forms.py:90 #: mailu/ui/forms.py:109 mailu/ui/forms.py:162 @@ -48,23 +49,23 @@ msgstr "כניסה" #: mailu/ui/templates/domain/details.html:27 #: mailu/ui/templates/domain/list.html:18 mailu/ui/templates/relay/list.html:17 msgid "Domain name" -msgstr "שם דומיין" +msgstr "שם תחום" #: mailu/ui/forms.py:47 msgid "Maximum user count" -msgstr "" +msgstr "כמות המשתמשים המרבית" #: mailu/ui/forms.py:48 msgid "Maximum alias count" -msgstr "" +msgstr "כמות הכינויים המרבית" #: mailu/ui/forms.py:49 msgid "Maximum user quota" -msgstr "" +msgstr "מיכסת המשתמשים המרבית" #: mailu/ui/forms.py:50 msgid "Enable sign-up" -msgstr "" +msgstr "לאפשר הרשמה" #: mailu/ui/forms.py:51 mailu/ui/forms.py:72 mailu/ui/forms.py:83 #: mailu/ui/forms.py:128 mailu/ui/forms.py:140 @@ -72,53 +73,53 @@ msgstr "" #: mailu/ui/templates/relay/list.html:19 mailu/ui/templates/token/list.html:19 #: mailu/ui/templates/user/list.html:23 msgid "Comment" -msgstr "" +msgstr "תגובה" #: mailu/ui/forms.py:52 mailu/ui/forms.py:61 mailu/ui/forms.py:66 #: mailu/ui/forms.py:73 mailu/ui/forms.py:132 mailu/ui/forms.py:141 msgid "Create" -msgstr "" +msgstr "יצירה" #: mailu/ui/forms.py:57 msgid "Initial admin" -msgstr "" +msgstr "מנהל ראשוני" #: mailu/ui/forms.py:58 msgid "Admin password" -msgstr "" +msgstr "סיסמת ניהול" #: mailu/ui/forms.py:59 mailu/ui/forms.py:79 mailu/ui/forms.py:91 msgid "Confirm password" -msgstr "" +msgstr "אישור סיסמה" #: mailu/ui/forms.py:65 msgid "Alternative name" -msgstr "" +msgstr "שם חלופי" #: mailu/ui/forms.py:70 msgid "Relayed domain name" -msgstr "" +msgstr "שם תחום מועבר" #: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18 msgid "Remote host" -msgstr "" +msgstr "מארח מרוחק" #: mailu/ui/forms.py:80 mailu/ui/templates/user/list.html:22 #: mailu/ui/templates/user/signup_domain.html:16 msgid "Quota" -msgstr "" +msgstr "מיכסה" #: mailu/ui/forms.py:81 msgid "Allow IMAP access" -msgstr "" +msgstr "לאפשר גישה ב־IMAP" #: mailu/ui/forms.py:82 msgid "Allow POP3 access" -msgstr "" +msgstr "לאפשר גישה ב־POP3" #: mailu/ui/forms.py:84 msgid "Enabled" -msgstr "" +msgstr "מופעל" #: mailu/ui/forms.py:85 msgid "Save" @@ -126,7 +127,7 @@ msgstr "שמירה" #: mailu/ui/forms.py:89 msgid "Email address" -msgstr "דואר אלקטרוני" +msgstr "כתובת דוא״ל" #: mailu/ui/forms.py:93 mailu/ui/templates/sidebar.html:117 #: mailu/ui/templates/user/signup.html:4 @@ -136,244 +137,244 @@ msgstr "הרשמה" #: mailu/ui/forms.py:97 msgid "Displayed name" -msgstr "" +msgstr "שם מוצג" #: mailu/ui/forms.py:98 msgid "Enable spam filter" -msgstr "" +msgstr "הפעלת מסנן ספאם" #: mailu/ui/forms.py:99 msgid "Spam filter tolerance" -msgstr "" +msgstr "סובלנות מסנן הספאם" #: mailu/ui/forms.py:100 msgid "Enable forwarding" -msgstr "" +msgstr "הפעלת העברה" #: mailu/ui/forms.py:101 msgid "Keep a copy of the emails" -msgstr "" +msgstr "להשאיר עותק מההודעות" #: mailu/ui/forms.py:103 mailu/ui/forms.py:139 #: mailu/ui/templates/alias/list.html:20 msgid "Destination" -msgstr "" +msgstr "יעד" #: mailu/ui/forms.py:105 msgid "Save settings" -msgstr "" +msgstr "שמירת הגדרות" #: mailu/ui/forms.py:110 msgid "Password check" -msgstr "" +msgstr "בדיקת סיסמה" #: mailu/ui/forms.py:111 mailu/ui/templates/sidebar.html:16 msgid "Update password" -msgstr "" +msgstr "עדכון סיסמה" #: mailu/ui/forms.py:115 msgid "Enable automatic reply" -msgstr "" +msgstr "הפעלת תגובה אוטומטית" #: mailu/ui/forms.py:116 msgid "Reply subject" -msgstr "" +msgstr "נושא התגובה" #: mailu/ui/forms.py:117 msgid "Reply body" -msgstr "" +msgstr "גוף התגובה" #: mailu/ui/forms.py:119 msgid "End of vacation" -msgstr "" +msgstr "סוף החופשה" #: mailu/ui/forms.py:120 msgid "Update" -msgstr "" +msgstr "עדכון" #: mailu/ui/forms.py:125 msgid "Your token (write it down, as it will never be displayed again)" -msgstr "" +msgstr "האסימון שלך (כדאי לשמור עליו היטב כיוון שהוא לא יופיע פעם נוספת)" #: mailu/ui/forms.py:130 mailu/ui/templates/token/list.html:20 msgid "Authorized IP" -msgstr "" +msgstr "כתובת IP מורשית" #: mailu/ui/forms.py:136 msgid "Alias" -msgstr "" +msgstr "כינוי" #: mailu/ui/forms.py:138 msgid "Use SQL LIKE Syntax (e.g. for catch-all aliases)" -msgstr "" +msgstr "להשתמש בתחביר דמוי SQL (למשל: catch-all aliases)" #: mailu/ui/forms.py:145 msgid "Admin email" -msgstr "" +msgstr "דוא״ל ההנהלה" #: mailu/ui/forms.py:146 mailu/ui/forms.py:151 mailu/ui/forms.py:164 msgid "Submit" -msgstr "" +msgstr "הגשה" #: mailu/ui/forms.py:150 msgid "Manager email" -msgstr "" +msgstr "דוא״ל המפקח" #: mailu/ui/forms.py:155 msgid "Protocol" -msgstr "" +msgstr "פרוטוקול" #: mailu/ui/forms.py:158 msgid "Hostname or IP" -msgstr "" +msgstr "שם מארח או כתובת IP" #: mailu/ui/forms.py:159 mailu/ui/templates/client.html:20 #: mailu/ui/templates/client.html:47 msgid "TCP port" -msgstr "" +msgstr "פתחת TCP" #: mailu/ui/forms.py:160 msgid "Enable TLS" -msgstr "" +msgstr "הפעלת TLS" #: mailu/ui/forms.py:161 mailu/ui/templates/client.html:28 #: mailu/ui/templates/client.html:55 mailu/ui/templates/fetch/list.html:20 msgid "Username" -msgstr "" +msgstr "שם משתמש" #: mailu/ui/forms.py:163 msgid "Keep emails on the server" -msgstr "" +msgstr "להשאיר את ההודעות על השרת" #: mailu/ui/forms.py:168 msgid "Announcement subject" -msgstr "" +msgstr "נושא ההכרזה" #: mailu/ui/forms.py:170 msgid "Announcement body" -msgstr "" +msgstr "גוף ההכרזה" #: mailu/ui/forms.py:172 msgid "Send" -msgstr "" +msgstr "שליחה" #: mailu/ui/templates/announcement.html:4 msgid "Public announcement" -msgstr "" +msgstr "הכרזה פומבית" #: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:82 msgid "Client setup" -msgstr "" +msgstr "הגדרת לקוח" #: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43 msgid "Mail protocol" -msgstr "" +msgstr "פרוטוקול דוא״ל" #: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51 msgid "Server name" -msgstr "" +msgstr "שם שרת" #: mailu/ui/templates/confirm.html:4 msgid "Confirm action" -msgstr "" +msgstr "אישור הפעולה" #: mailu/ui/templates/confirm.html:13 #, python-format msgid "You are about to %(action)s. Please confirm your action." -msgstr "" +msgstr "פעולה זו תבצע %(action)s. נא לאשר את הפעולה שלך." #: mailu/ui/templates/docker-error.html:4 msgid "Docker error" -msgstr "" +msgstr "שגיאת Docker" #: mailu/ui/templates/docker-error.html:12 msgid "An error occurred while talking to the Docker server." -msgstr "" +msgstr "אירעה שגיאה בעת החיבור לשרת ה־Docker." #: mailu/ui/templates/login.html:8 msgid "to access the administration tools" -msgstr "" +msgstr "כדי לגשת לכלי הניהול" #: mailu/ui/templates/sidebar.html:11 mailu/ui/templates/user/list.html:34 msgid "Settings" -msgstr "" +msgstr "הגדרות" #: mailu/ui/templates/sidebar.html:21 mailu/ui/templates/user/list.html:35 msgid "Auto-reply" -msgstr "" +msgstr "מענה אוטומטית" #: mailu/ui/templates/fetch/list.html:4 mailu/ui/templates/sidebar.html:26 #: mailu/ui/templates/user/list.html:36 msgid "Fetched accounts" -msgstr "" +msgstr "חשבונות נמשכים" #: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4 msgid "Authentication tokens" -msgstr "" +msgstr "אסימוני אימות" #: mailu/ui/templates/sidebar.html:35 msgid "Administration" -msgstr "" +msgstr "ניהול" #: mailu/ui/templates/sidebar.html:44 msgid "Announcement" -msgstr "" +msgstr "הכרזה" #: mailu/ui/templates/sidebar.html:49 msgid "Administrators" -msgstr "" +msgstr "מנהלים" #: mailu/ui/templates/sidebar.html:54 msgid "Relayed domains" -msgstr "" +msgstr "שמות תחום מועברים" #: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15 msgid "Antispam" -msgstr "" +msgstr "מניעת ספאם" #: mailu/ui/templates/sidebar.html:66 msgid "Mail domains" -msgstr "" +msgstr "דמות תחום לדוא״ל" #: mailu/ui/templates/sidebar.html:72 msgid "Go to" -msgstr "" +msgstr "מעבר אל" #: mailu/ui/templates/sidebar.html:76 msgid "Webmail" -msgstr "" +msgstr "דוא״ל בדפדפן" #: mailu/ui/templates/sidebar.html:87 msgid "Website" -msgstr "" +msgstr "אתר" #: mailu/ui/templates/sidebar.html:92 msgid "Help" -msgstr "" +msgstr "עזרה" #: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:98 msgid "Register a domain" -msgstr "" +msgstr "רישום שם תחום" #: mailu/ui/templates/sidebar.html:105 msgid "Sign out" -msgstr "" +msgstr "יציאה" #: mailu/ui/templates/working.html:4 msgid "We are still working on this feature!" -msgstr "" +msgstr "אנחנו עדיין עובדים על היכולת הזאת!" #: mailu/ui/templates/admin/create.html:4 msgid "Add a global administrator" -msgstr "" +msgstr "הוספת מנהל כללי" #: mailu/ui/templates/admin/list.html:4 msgid "Global administrators" -msgstr "" +msgstr "מנהלים כלליים" #: mailu/ui/templates/admin/list.html:9 msgid "Add administrator" -msgstr "" +msgstr "הוספת מנהל" #: mailu/ui/templates/admin/list.html:16 mailu/ui/templates/alias/list.html:18 #: mailu/ui/templates/alternative/list.html:18 @@ -382,12 +383,12 @@ msgstr "" #: mailu/ui/templates/relay/list.html:16 mailu/ui/templates/token/list.html:18 #: mailu/ui/templates/user/list.html:18 msgid "Actions" -msgstr "" +msgstr "פעולות" #: mailu/ui/templates/admin/list.html:17 mailu/ui/templates/alias/list.html:19 #: mailu/ui/templates/manager/list.html:19 mailu/ui/templates/user/list.html:20 msgid "Email" -msgstr "" +msgstr "דוא״ל" #: mailu/ui/templates/admin/list.html:22 mailu/ui/templates/alias/list.html:29 #: mailu/ui/templates/alternative/list.html:25 @@ -396,23 +397,23 @@ msgstr "" #: mailu/ui/templates/relay/list.html:27 mailu/ui/templates/token/list.html:26 #: mailu/ui/templates/user/list.html:31 msgid "Delete" -msgstr "" +msgstr "מחיקה" #: mailu/ui/templates/alias/create.html:4 msgid "Create alias" -msgstr "" +msgstr "יצירת כינוי" #: mailu/ui/templates/alias/edit.html:4 msgid "Edit alias" -msgstr "" +msgstr "עריכת כינוי" #: mailu/ui/templates/alias/list.html:4 msgid "Alias list" -msgstr "" +msgstr "רשימת כינויים" #: mailu/ui/templates/alias/list.html:12 msgid "Add alias" -msgstr "" +msgstr "הוספת כינוי" #: mailu/ui/templates/alias/list.html:22 #: mailu/ui/templates/alternative/list.html:20 @@ -420,254 +421,259 @@ msgstr "" #: mailu/ui/templates/relay/list.html:20 mailu/ui/templates/token/list.html:21 #: mailu/ui/templates/user/list.html:24 msgid "Created" -msgstr "" +msgstr "נוצר" #: mailu/ui/templates/alias/list.html:23 mailu/ui/templates/domain/list.html:23 #: mailu/ui/templates/fetch/list.html:25 mailu/ui/templates/relay/list.html:21 #: mailu/ui/templates/user/list.html:25 msgid "Last edit" -msgstr "" +msgstr "עריכה אחרונה" #: mailu/ui/templates/alias/list.html:28 mailu/ui/templates/domain/list.html:30 #: mailu/ui/templates/fetch/list.html:30 mailu/ui/templates/relay/list.html:26 #: mailu/ui/templates/user/list.html:30 msgid "Edit" -msgstr "" +msgstr "עריכה" #: mailu/ui/templates/alternative/create.html:4 msgid "Create alternative domain" -msgstr "" +msgstr "יצירת שם תחום חלופי" #: mailu/ui/templates/alternative/list.html:4 msgid "Alternative domain list" -msgstr "" +msgstr "רשימת שמות תחום חלופיים" #: mailu/ui/templates/alternative/list.html:12 msgid "Add alternative" -msgstr "" +msgstr "הוספת חלופה" #: mailu/ui/templates/alternative/list.html:19 msgid "Name" -msgstr "" +msgstr "שם" #: mailu/ui/templates/domain/create.html:4 #: mailu/ui/templates/domain/list.html:9 msgid "New domain" -msgstr "" +msgstr "שם תחום חדש" #: mailu/ui/templates/domain/details.html:4 msgid "Domain details" -msgstr "" +msgstr "פרטי שם התחום" #: mailu/ui/templates/domain/details.html:15 msgid "Regenerate keys" -msgstr "" +msgstr "יצירת מפתחות מחדש" #: mailu/ui/templates/domain/details.html:17 msgid "Generate keys" -msgstr "" +msgstr "יצירת מפתחות" #: mailu/ui/templates/domain/details.html:31 msgid "DNS MX entry" -msgstr "" +msgstr "רשומת MX ב־DNS" #: mailu/ui/templates/domain/details.html:35 msgid "DNS SPF entries" -msgstr "" +msgstr "רשומות SPF ב־DNS" #: mailu/ui/templates/domain/details.html:42 msgid "DKIM public key" -msgstr "" +msgstr "מפתח DKIM ציבורי" #: mailu/ui/templates/domain/details.html:46 msgid "DNS DKIM entry" -msgstr "" +msgstr "רשומת DKIM ב־DNS" #: mailu/ui/templates/domain/details.html:50 msgid "DNS DMARC entry" -msgstr "" +msgstr "רשומת DMARC ב־DNS" #: mailu/ui/templates/domain/edit.html:4 msgid "Edit domain" -msgstr "" +msgstr "עריכת שם תחום" #: mailu/ui/templates/domain/list.html:4 msgid "Domain list" -msgstr "" +msgstr "רשימת שמות תחום" #: mailu/ui/templates/domain/list.html:17 msgid "Manage" -msgstr "" +msgstr "ניהול" #: mailu/ui/templates/domain/list.html:19 msgid "Mailbox count" -msgstr "" +msgstr "כמות תיבות דוא״ל" #: mailu/ui/templates/domain/list.html:20 msgid "Alias count" -msgstr "" +msgstr "כמות כינויים" #: mailu/ui/templates/domain/list.html:28 msgid "Details" -msgstr "" +msgstr "פרטים" #: mailu/ui/templates/domain/list.html:35 msgid "Users" -msgstr "" +msgstr "משתמשים" #: mailu/ui/templates/domain/list.html:36 msgid "Aliases" -msgstr "" +msgstr "כינויים" #: mailu/ui/templates/domain/list.html:37 msgid "Managers" -msgstr "" +msgstr "מפקחים" #: mailu/ui/templates/domain/list.html:39 msgid "Alternatives" -msgstr "" +msgstr "חלופות" #: mailu/ui/templates/domain/signup.html:13 msgid "" "In order to register a new domain, you must first setup the\n" " domain zone so that the domain MX points to this server" msgstr "" +"כדי לרשום שם תחום חדש, תחילה עליך להקים את אזור התחום\n" +" ‏(domain zone) כדי שה־MX של שם התחום יפנה לשרת הזה" #: mailu/ui/templates/domain/signup.html:18 msgid "" "If you do not know how to setup an MX record for your DNS " "zone,\n" -" please contact your DNS provider or administrator. Also, please wait " -"a\n" +" please contact your DNS provider or administrator. Also, please wait a\n" " couple minutes after the MX is set so the local server " "cache\n" " expires." msgstr "" +"אם לא ברור לך איך להקים רשומת MX עבור אזור ה־DNS שלך,\n" +" נא ליצור קשר עם ספק ה־ DNS או ההנהלה שלך. כמו כן, נא להמתין מספר דקות\n" +" לאחר הגדרת ה־MX כדי לאפשר לתוקף המטמון המקורי בשרת\n" +" לפוג." #: mailu/ui/templates/fetch/create.html:4 msgid "Add a fetched account" -msgstr "" +msgstr "הוספת חשבון נמשך" #: mailu/ui/templates/fetch/edit.html:4 msgid "Update a fetched account" -msgstr "" +msgstr "עדכון חשבון שנמשך" #: mailu/ui/templates/fetch/list.html:12 msgid "Add an account" -msgstr "" +msgstr "הוספת חשבון" #: mailu/ui/templates/fetch/list.html:19 msgid "Endpoint" -msgstr "" +msgstr "נקודת גישה" #: mailu/ui/templates/fetch/list.html:21 msgid "Keep emails" -msgstr "" +msgstr "לשמור על ההודעות" #: mailu/ui/templates/fetch/list.html:22 msgid "Last check" -msgstr "" +msgstr "בדיקה אחרונה" #: mailu/ui/templates/fetch/list.html:35 msgid "yes" -msgstr "" +msgstr "כן" #: mailu/ui/templates/fetch/list.html:35 msgid "no" -msgstr "" +msgstr "לא" #: mailu/ui/templates/manager/create.html:4 msgid "Add a manager" -msgstr "" +msgstr "הוספת מנהל" #: mailu/ui/templates/manager/list.html:4 msgid "Manager list" -msgstr "" +msgstr "רשימת מנהלים" #: mailu/ui/templates/manager/list.html:12 msgid "Add manager" -msgstr "" +msgstr "הוספת מנהל" #: mailu/ui/templates/relay/create.html:4 msgid "New relay domain" -msgstr "" +msgstr "שם תחום מועבר" #: mailu/ui/templates/relay/edit.html:4 msgid "Edit relayd domain" -msgstr "" +msgstr "עריכת שמות תחום מועברים" #: mailu/ui/templates/relay/list.html:4 msgid "Relayed domain list" -msgstr "" +msgstr "רשימת שמות תחום מועברים" #: mailu/ui/templates/relay/list.html:9 msgid "New relayed domain" -msgstr "" +msgstr "שם תחום מועבר חדש" #: mailu/ui/templates/token/create.html:4 msgid "Create an authentication token" -msgstr "" +msgstr "יצירת אסימון אימות" #: mailu/ui/templates/token/list.html:12 msgid "New token" -msgstr "" +msgstr "אסימון חדש" #: mailu/ui/templates/user/create.html:4 msgid "New user" -msgstr "" +msgstr "משתמש חדש" #: mailu/ui/templates/user/create.html:15 msgid "General" -msgstr "" +msgstr "כללי" #: mailu/ui/templates/user/create.html:22 msgid "Features and quotas" -msgstr "" +msgstr "יכולות ומיכסות" #: mailu/ui/templates/user/edit.html:4 msgid "Edit user" -msgstr "" +msgstr "עריכת משתמש" #: mailu/ui/templates/user/forward.html:4 msgid "Forward emails" -msgstr "" +msgstr "העברת הודעות" #: mailu/ui/templates/user/list.html:4 msgid "User list" -msgstr "" +msgstr "רשימת משתמשים" #: mailu/ui/templates/user/list.html:12 msgid "Add user" -msgstr "" +msgstr "הוספת משתמש" #: mailu/ui/templates/user/list.html:19 mailu/ui/templates/user/settings.html:4 msgid "User settings" -msgstr "" +msgstr "הגדרות משתמש" #: mailu/ui/templates/user/list.html:21 msgid "Features" -msgstr "" +msgstr "יכולות" #: mailu/ui/templates/user/password.html:4 msgid "Password update" -msgstr "" +msgstr "עדכון סיסמה" #: mailu/ui/templates/user/reply.html:4 msgid "Automatic reply" -msgstr "" +msgstr "מענה אוטומטי" #: mailu/ui/templates/user/settings.html:22 msgid "Auto-forward" -msgstr "" +msgstr "העברה אוטומטית" #: mailu/ui/templates/user/signup_domain.html:8 msgid "pick a domain for the new account" -msgstr "" +msgstr "נא לבחור שם תחום לחשבון החדש" #: mailu/ui/templates/user/signup_domain.html:14 msgid "Domain" -msgstr "" +msgstr "שם תחום" #: mailu/ui/templates/user/signup_domain.html:15 msgid "Available slots" -msgstr "" +msgstr "מקומות פנויים" diff --git a/core/admin/mailu/translations/it/LC_MESSAGES/messages.po b/core/admin/mailu/translations/it/LC_MESSAGES/messages.po index 9ef5ac84..9ed5e132 100644 --- a/core/admin/mailu/translations/it/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/it/LC_MESSAGES/messages.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Mailu\n" -"PO-Revision-Date: 2020-03-11 23:03+0000\n" +"PO-Revision-Date: 2021-03-04 18:46+0000\n" "Last-Translator: Jaume Barber \n" "Language-Team: Italian \n" @@ -10,7 +10,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.11.2\n" +"X-Generator: Weblate 4.0.1\n" #: mailu/ui/forms.py:32 msgid "Invalid email address." diff --git a/core/admin/mailu/translations/nl/LC_MESSAGES/messages.po b/core/admin/mailu/translations/nl/LC_MESSAGES/messages.po index 8d7b5054..c7f6db5e 100644 --- a/core/admin/mailu/translations/nl/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/nl/LC_MESSAGES/messages.po @@ -528,7 +528,7 @@ msgstr "Alternatieve naam" #: mailu/ui/forms.py:70 msgid "Relayed domain name" -msgstr "Relayed domainnaam" +msgstr "Relayed domeinnaam" #: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18 msgid "Remote host" @@ -536,7 +536,7 @@ msgstr "Externe host" #: mailu/ui/templates/sidebar.html:54 msgid "Relayed domains" -msgstr "Relayed domainen" +msgstr "Relayed domeinen" #: mailu/ui/templates/alternative/create.html:4 msgid "Create alternative domain" diff --git a/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po b/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po index cec7a4a0..09130a7b 100644 --- a/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po @@ -1,188 +1,287 @@ msgid "" msgstr "" -"Project-Id-Version: Mailu\n" +"Project-Id-Version: Mailu\n" +"POT-Creation-Date: 2021-02-05 16:34+0100\n" "PO-Revision-Date: 2020-02-17 20:23+0000\n" -"Last-Translator: NeroPcStation \n" -"Language-Team: Polish \n" +"Last-Translator: Marcin Siennicki \n" "Language: pl\n" +"Language-Team: Polish " +"\n" +"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && " +"(n%100<10 || n%100>=20) ? 1 : 2\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" +"Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " -"|| n%100>=20) ? 1 : 2;\n" -"X-Generator: Weblate 3.3\n" +"Generated-By: Babel 2.9.0\n" -#: mailu/ui/forms.py:32 +#: mailu/ui/forms.py:33 mailu/ui/forms.py:36 msgid "Invalid email address." msgstr "Nieprawidłowy adres e-mail." -#: mailu/ui/forms.py:36 +#: mailu/ui/forms.py:45 msgid "Confirm" msgstr "Zatwierdź" -#: mailu/ui/forms.py:40 mailu/ui/forms.py:77 +#: mailu/ui/forms.py:49 mailu/ui/forms.py:86 msgid "E-mail" msgstr "E-mail" -#: mailu/ui/forms.py:41 mailu/ui/forms.py:78 mailu/ui/forms.py:90 -#: mailu/ui/forms.py:109 mailu/ui/forms.py:162 +#: mailu/ui/forms.py:50 mailu/ui/forms.py:87 mailu/ui/forms.py:100 +#: mailu/ui/forms.py:118 mailu/ui/forms.py:172 #: mailu/ui/templates/client.html:32 mailu/ui/templates/client.html:59 msgid "Password" msgstr "Hasło" -#: mailu/ui/forms.py:42 mailu/ui/templates/login.html:4 -#: mailu/ui/templates/sidebar.html:111 +#: mailu/ui/forms.py:51 mailu/ui/templates/login.html:4 +#: mailu/ui/templates/sidebar.html:108 msgid "Sign in" msgstr "Zaloguj" -#: mailu/ui/forms.py:46 mailu/ui/forms.py:56 +#: mailu/ui/forms.py:55 mailu/ui/forms.py:65 #: mailu/ui/templates/domain/details.html:27 #: mailu/ui/templates/domain/list.html:18 mailu/ui/templates/relay/list.html:17 msgid "Domain name" msgstr "Nazwa domeny" -#: mailu/ui/forms.py:47 +#: mailu/ui/forms.py:56 msgid "Maximum user count" msgstr "Maksymalna liczba użytkowników" -#: mailu/ui/forms.py:48 +#: mailu/ui/forms.py:57 msgid "Maximum alias count" msgstr "Maksymalna liczba aliasów" -#. Needs more context - is that a verb or a noun? -#: mailu/ui/forms.py:51 mailu/ui/forms.py:72 mailu/ui/forms.py:83 -#: mailu/ui/forms.py:128 mailu/ui/forms.py:140 +#: mailu/ui/forms.py:58 +msgid "Maximum user quota" +msgstr "Maksymalny przydział użytkownika" + +#: mailu/ui/forms.py:59 +msgid "Enable sign-up" +msgstr "Włącz rejestrację" + +#: mailu/ui/forms.py:60 mailu/ui/forms.py:81 mailu/ui/forms.py:93 +#: mailu/ui/forms.py:138 mailu/ui/forms.py:150 #: mailu/ui/templates/alias/list.html:21 mailu/ui/templates/domain/list.html:21 #: mailu/ui/templates/relay/list.html:19 mailu/ui/templates/token/list.html:19 #: mailu/ui/templates/user/list.html:23 msgid "Comment" msgstr "Komentarz" -#: mailu/ui/forms.py:52 mailu/ui/forms.py:61 mailu/ui/forms.py:66 -#: mailu/ui/forms.py:73 mailu/ui/forms.py:132 mailu/ui/forms.py:141 -msgid "Create" -msgstr "Utwórz" +#: mailu/ui/forms.py:61 mailu/ui/forms.py:75 mailu/ui/forms.py:82 +#: mailu/ui/forms.py:95 mailu/ui/forms.py:142 mailu/ui/forms.py:151 +msgid "Save" +msgstr "Zapisz" -#: mailu/ui/forms.py:59 mailu/ui/forms.py:79 mailu/ui/forms.py:91 +#: mailu/ui/forms.py:66 +msgid "Initial admin" +msgstr "Początkowy administrator" + +#: mailu/ui/forms.py:67 +msgid "Admin password" +msgstr "Hasło administratora" + +#: mailu/ui/forms.py:68 mailu/ui/forms.py:88 mailu/ui/forms.py:101 msgid "Confirm password" msgstr "Potwierdź hasło" -#: mailu/ui/forms.py:80 mailu/ui/templates/user/list.html:22 +#: mailu/ui/forms.py:70 +msgid "Create" +msgstr "Utwórz" + +#: mailu/ui/forms.py:74 +msgid "Alternative name" +msgstr "Alternatywna nazwa" + +#: mailu/ui/forms.py:79 +msgid "Relayed domain name" +msgstr "Domeny przekierowywane" + +#: mailu/ui/forms.py:80 mailu/ui/templates/relay/list.html:18 +msgid "Remote host" +msgstr "Zdalny host" + +#: mailu/ui/forms.py:89 mailu/ui/templates/user/list.html:22 #: mailu/ui/templates/user/signup_domain.html:16 msgid "Quota" msgstr "Maksymalna przestrzeń na dysku" -#: mailu/ui/forms.py:81 +#: mailu/ui/forms.py:90 msgid "Allow IMAP access" msgstr "Zezwalaj na dostęp przez protokół IMAP" -#: mailu/ui/forms.py:82 +#: mailu/ui/forms.py:91 msgid "Allow POP3 access" msgstr "Zezwalaj na dostęp przez protokół POP3" -#: mailu/ui/forms.py:85 -msgid "Save" -msgstr "Zapisz" - -#: mailu/ui/forms.py:97 +#: mailu/ui/forms.py:92 mailu/ui/forms.py:108 +#: mailu/ui/templates/user/settings.html:15 msgid "Displayed name" msgstr "Nazwa wyświetlana" -#: mailu/ui/forms.py:98 +#: mailu/ui/forms.py:94 +msgid "Enabled" +msgstr "Włączone" + +#: mailu/ui/forms.py:99 +msgid "Email address" +msgstr "Adres e-mail" + +#: mailu/ui/forms.py:102 mailu/ui/templates/sidebar.html:114 +#: mailu/ui/templates/user/signup.html:4 +#: mailu/ui/templates/user/signup_domain.html:4 +msgid "Sign up" +msgstr "Utwórz konto" + +#: mailu/ui/forms.py:109 msgid "Enable spam filter" msgstr "Włącz filtr antyspamowy" -#: mailu/ui/forms.py:80 -msgid "Spam filter threshold" -msgstr "Próg filtra antyspamowego" - -#: mailu/ui/forms.py:105 -msgid "Save settings" -msgstr "Zapisz ustawienia" - #: mailu/ui/forms.py:110 -msgid "Password check" -msgstr "" +msgid "Spam filter tolerance" +msgstr "Tolerancja filtra spamu" -#: mailu/ui/forms.py:111 mailu/ui/templates/sidebar.html:16 -msgid "Update password" -msgstr "Zaktualizuj hasło" - -#: mailu/ui/forms.py:100 +#: mailu/ui/forms.py:111 msgid "Enable forwarding" msgstr "Włącz przekierowanie poczty" -#: mailu/ui/forms.py:103 mailu/ui/forms.py:139 +#: mailu/ui/forms.py:112 +msgid "Keep a copy of the emails" +msgstr "Przechowuj kopię wiadomości" + +#: mailu/ui/forms.py:113 mailu/ui/forms.py:149 #: mailu/ui/templates/alias/list.html:20 msgid "Destination" msgstr "Adres docelowy" -#: mailu/ui/forms.py:120 -msgid "Update" -msgstr "Aktualizuj" +#: mailu/ui/forms.py:114 +msgid "Save settings" +msgstr "Zapisz ustawienia" -#: mailu/ui/forms.py:115 +#: mailu/ui/forms.py:119 +msgid "Password check" +msgstr "Powtórz hasło" + +#: mailu/ui/forms.py:120 mailu/ui/templates/sidebar.html:16 +msgid "Update password" +msgstr "Zaktualizuj hasło" + +#: mailu/ui/forms.py:124 msgid "Enable automatic reply" msgstr "Włącz automatyczną odpowiedź" -#: mailu/ui/forms.py:116 +#: mailu/ui/forms.py:125 msgid "Reply subject" msgstr "Temat odpowiedzi" -#: mailu/ui/forms.py:117 +#: mailu/ui/forms.py:126 msgid "Reply body" msgstr "Treść odpowiedzi" -#: mailu/ui/forms.py:136 +#: mailu/ui/forms.py:128 +#, fuzzy +msgid "Start of vacation" +msgstr "Rozpoczęcie nieobecności" + +#: mailu/ui/forms.py:129 +msgid "End of vacation" +msgstr "Koniec nieobecności" + +#: mailu/ui/forms.py:130 +msgid "Update" +msgstr "Aktualizuj" + +#: mailu/ui/forms.py:135 +msgid "Your token (write it down, as it will never be displayed again)" +msgstr "Twój token (zapisz go, ponieważ nigdy więcej nie będzie wyświetlany)" + +#: mailu/ui/forms.py:140 mailu/ui/templates/token/list.html:20 +msgid "Authorized IP" +msgstr "Autoryzowany adres IP" + +#: mailu/ui/forms.py:146 msgid "Alias" msgstr "Alias" -#: mailu/ui/forms.py:138 +#: mailu/ui/forms.py:148 msgid "Use SQL LIKE Syntax (e.g. for catch-all aliases)" msgstr "Używaj składni SQL LIKE (np. do adresów catch-all)" -#: mailu/ui/forms.py:145 +#: mailu/ui/forms.py:155 msgid "Admin email" msgstr "E-mail administratora" -#: mailu/ui/forms.py:146 mailu/ui/forms.py:151 mailu/ui/forms.py:164 +#: mailu/ui/forms.py:156 mailu/ui/forms.py:161 mailu/ui/forms.py:174 msgid "Submit" msgstr "Prześlij" -#: mailu/ui/forms.py:150 +#: mailu/ui/forms.py:160 msgid "Manager email" msgstr "E-mail menedżera" -#: mailu/ui/forms.py:155 +#: mailu/ui/forms.py:165 msgid "Protocol" msgstr "Protokół" -#: mailu/ui/forms.py:158 +#: mailu/ui/forms.py:168 msgid "Hostname or IP" msgstr "Nazwa hosta lub adres IP" -#: mailu/ui/forms.py:159 mailu/ui/templates/client.html:20 +#: mailu/ui/forms.py:169 mailu/ui/templates/client.html:20 #: mailu/ui/templates/client.html:47 msgid "TCP port" msgstr "Port TCP" -#: mailu/ui/forms.py:160 +#: mailu/ui/forms.py:170 msgid "Enable TLS" msgstr "Włącz TLS" -#: mailu/ui/forms.py:161 mailu/ui/templates/client.html:28 +#: mailu/ui/forms.py:171 mailu/ui/templates/client.html:28 #: mailu/ui/templates/client.html:55 mailu/ui/templates/fetch/list.html:20 msgid "Username" msgstr "Nazwa użytkownika" +#: mailu/ui/forms.py:173 +msgid "Keep emails on the server" +msgstr "Przechowuj wiadomości na serwerze" + +#: mailu/ui/forms.py:178 +msgid "Announcement subject" +msgstr "Temat ogłoszenia" + +#: mailu/ui/forms.py:180 +msgid "Announcement body" +msgstr "Treść ogłoszenia" + +#: mailu/ui/forms.py:182 +msgid "Send" +msgstr "Wyślij" + +#: mailu/ui/templates/announcement.html:4 +msgid "Public announcement" +msgstr "Publiczne ogłoszenie" + +#: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:79 +msgid "Client setup" +msgstr "Konfiguracja klienta" + +#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43 +msgid "Mail protocol" +msgstr "Protokół poczty" + +#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51 +msgid "Server name" +msgstr "Nazwa serwera" + #: mailu/ui/templates/confirm.html:4 msgid "Confirm action" msgstr "Potwierdź wykonanie czynności" #: mailu/ui/templates/confirm.html:13 +#, python-format msgid "You are about to %(action)s. Please confirm your action." -msgstr "Zamierzasz wykonać następujące czynności: %(action)s. Potwierdź wykonanie czynności." +msgstr "" +"Zamierzasz wykonać następujące czynności: %(action)s. Potwierdź wykonanie" +" czynności." #: mailu/ui/templates/docker-error.html:4 msgid "Docker error" @@ -192,54 +291,19 @@ msgstr "Błąd Dockera" msgid "An error occurred while talking to the Docker server." msgstr "Wystąpił błąd komunikacji z serwerem Dockera." -#: mailu/admin/templates/login.html:6 -msgid "Your account" -msgstr "Twoje konto" - #: mailu/ui/templates/login.html:8 msgid "to access the administration tools" msgstr "aby uzyskać dostęp do narzędzi administracyjnych" -#: mailu/ui/templates/services.html:4 mailu/ui/templates/sidebar.html:39 -msgid "Services status" -msgstr "Status usług" - -#: mailu/ui/templates/services.html:10 -msgid "Service" -msgstr "Usługa" - -#: mailu/ui/templates/fetch/list.html:23 mailu/ui/templates/services.html:11 -msgid "Status" -msgstr "Status" - -#: mailu/ui/templates/services.html:12 -msgid "PID" -msgstr "PID" - -#: mailu/ui/templates/services.html:13 -msgid "Image" -msgstr "Obraz" - -#: mailu/ui/templates/services.html:14 -msgid "Started" -msgstr "" - -#: mailu/ui/templates/services.html:15 -msgid "Last update" -msgstr "Ostatnia aktualizacja" - #: mailu/ui/templates/sidebar.html:8 +#, fuzzy msgid "My account" -msgstr "Moje konto" +msgstr "Dodaj konto" #: mailu/ui/templates/sidebar.html:11 mailu/ui/templates/user/list.html:34 msgid "Settings" msgstr "Ustawienia" -#: mailu/ui/templates/user/settings.html:22 -msgid "Auto-forward" -msgstr "Automatyczne przekierowanie" - #: mailu/ui/templates/sidebar.html:21 mailu/ui/templates/user/list.html:35 msgid "Auto-reply" msgstr "Automatyczna odpowiedź" @@ -247,28 +311,60 @@ msgstr "Automatyczna odpowiedź" #: mailu/ui/templates/fetch/list.html:4 mailu/ui/templates/sidebar.html:26 #: mailu/ui/templates/user/list.html:36 msgid "Fetched accounts" -msgstr "" +msgstr "Zewnętrzne konta e-mail" -#: mailu/ui/templates/sidebar.html:105 -msgid "Sign out" -msgstr "Wyloguj" +#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4 +msgid "Authentication tokens" +msgstr "Tokeny uwierzytelnienia" -#: mailu/ui/templates/sidebar.html:35 +#: mailu/ui/templates/sidebar.html:36 msgid "Administration" msgstr "Administracja" -#: mailu/ui/templates/sidebar.html:49 +#: mailu/ui/templates/sidebar.html:41 +msgid "Announcement" +msgstr "Ogłoszenie" + +#: mailu/ui/templates/sidebar.html:46 msgid "Administrators" msgstr "Administratorzy" -#: mailu/ui/templates/sidebar.html:66 +#: mailu/ui/templates/sidebar.html:51 +msgid "Relayed domains" +msgstr "Domeny przekierowywane" + +#: mailu/ui/templates/sidebar.html:56 mailu/ui/templates/user/settings.html:19 +msgid "Antispam" +msgstr "Filtr antyspamowy" + +#: mailu/ui/templates/sidebar.html:63 msgid "Mail domains" msgstr "Domeny pocztowe" -#: mailu/ui/templates/sidebar.html:92 +#: mailu/ui/templates/sidebar.html:69 +msgid "Go to" +msgstr "Przejdź do" + +#: mailu/ui/templates/sidebar.html:73 +msgid "Webmail" +msgstr "Twoja poczta" + +#: mailu/ui/templates/sidebar.html:84 +msgid "Website" +msgstr "Strona internetowa" + +#: mailu/ui/templates/sidebar.html:89 msgid "Help" msgstr "Pomoc" +#: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:95 +msgid "Register a domain" +msgstr "Zarejestruj domenę" + +#: mailu/ui/templates/sidebar.html:102 +msgid "Sign out" +msgstr "Wyloguj" + #: mailu/ui/templates/working.html:4 msgid "We are still working on this feature!" msgstr "Nadal pracujemy nad tą funkcją!" @@ -344,6 +440,22 @@ msgstr "Ostatnia edycja" msgid "Edit" msgstr "Edytuj" +#: mailu/ui/templates/alternative/create.html:4 +msgid "Create alternative domain" +msgstr "Utwórz alternatywną domenę" + +#: mailu/ui/templates/alternative/list.html:4 +msgid "Alternative domain list" +msgstr "Alternatywna lista domen" + +#: mailu/ui/templates/alternative/list.html:12 +msgid "Add alternative" +msgstr "Dodaj alternatywę" + +#: mailu/ui/templates/alternative/list.html:19 +msgid "Name" +msgstr "Nazwa" + #: mailu/ui/templates/domain/create.html:4 #: mailu/ui/templates/domain/list.html:9 msgid "New domain" @@ -357,6 +469,10 @@ msgstr "Szczegóły domeny" msgid "Regenerate keys" msgstr "Wygeneruj ponownie klucze" +#: mailu/ui/templates/domain/details.html:17 +msgid "Generate keys" +msgstr "Wygeneruj klucze" + #: mailu/ui/templates/domain/details.html:31 msgid "DNS MX entry" msgstr "Wpis MX DNS" @@ -365,15 +481,15 @@ msgstr "Wpis MX DNS" msgid "DNS SPF entries" msgstr "Wpisy SPF DNS" -#: mailu/ui/templates/domain/details.html:42 +#: mailu/ui/templates/domain/details.html:41 msgid "DKIM public key" msgstr "Publiczny klucz DKIM" -#: mailu/ui/templates/domain/details.html:46 +#: mailu/ui/templates/domain/details.html:45 msgid "DNS DKIM entry" msgstr "Wpis DKIM DNS" -#: mailu/ui/templates/domain/details.html:50 +#: mailu/ui/templates/domain/details.html:49 msgid "DNS DMARC entry" msgstr "Wpis DMARC DNS" @@ -413,13 +529,42 @@ msgstr "Aliasy" msgid "Managers" msgstr "Menedżerowie" +#: mailu/ui/templates/domain/list.html:39 +msgid "Alternatives" +msgstr "Alternatywy" + +#: mailu/ui/templates/domain/signup.html:13 +msgid "" +"In order to register a new domain, you must first setup the\n" +" domain zone so that the domain MX points to this server" +msgstr "" +"Aby zarejestrować nową domenę, musisz najpierw skonfigurować strefę " +"domeny, aby domena MX wskazywała na ten serwer" + +#: mailu/ui/templates/domain/signup.html:18 +msgid "" +"If you do not know how to setup an MX record for your DNS " +"zone,\n" +" please contact your DNS provider or administrator. Also, please wait " +"a\n" +" couple minutes after the MX is set so the local server " +"cache\n" +" expires." +msgstr "" +"Jeśli nie wiesz, jak skonfigurować rekord MX dla swojej " +"strefy DNS,\n" +"skontaktuj się z dostawcą DNS lub administratorem. Proszę również " +"poczekać\n" +"kilka minut po ustawieniu MX , żeby pamięć podręczna " +"serwera lokalnego wygasła." + #: mailu/ui/templates/fetch/create.html:4 msgid "Add a fetched account" -msgstr "" +msgstr "Dodaj zewnętrzne konto pocztowe" #: mailu/ui/templates/fetch/edit.html:4 msgid "Update a fetched account" -msgstr "" +msgstr "Zaktualizuj konto" #: mailu/ui/templates/fetch/list.html:12 msgid "Add an account" @@ -427,12 +572,28 @@ msgstr "Dodaj konto" #: mailu/ui/templates/fetch/list.html:19 msgid "Endpoint" -msgstr "" +msgstr "Serwer" + +#: mailu/ui/templates/fetch/list.html:21 +msgid "Keep emails" +msgstr "Przechowuj wiadomości" #: mailu/ui/templates/fetch/list.html:22 msgid "Last check" msgstr "Ostatnie sprawdzenie" +#: mailu/ui/templates/fetch/list.html:23 +msgid "Status" +msgstr "Stan" + +#: mailu/ui/templates/fetch/list.html:35 +msgid "yes" +msgstr "Tak" + +#: mailu/ui/templates/fetch/list.html:35 +msgid "no" +msgstr "Nie" + #: mailu/ui/templates/manager/create.html:4 msgid "Add a manager" msgstr "Dodaj menedżera" @@ -445,34 +606,43 @@ msgstr "Lista menedżerów" msgid "Add manager" msgstr "Dodaj menedżera" -#: mailu/ui/forms.py:168 -msgid "Announcement subject" -msgstr "Temat ogłoszenia" +#: mailu/ui/templates/relay/create.html:4 +msgid "New relay domain" +msgstr "Nowa domena do przekierowania" -#: mailu/ui/forms.py:170 -msgid "Announcement body" -msgstr "Treść ogłoszenia" +#: mailu/ui/templates/relay/edit.html:4 +#, fuzzy +msgid "Edit relayd domain" +msgstr "Edycja domeny" -#: mailu/ui/forms.py:172 -msgid "Send" -msgstr "Wyślij" +#: mailu/ui/templates/relay/list.html:4 +msgid "Relayed domain list" +msgstr "Lista domen przekierowywanych" -#: mailu/ui/templates/announcement.html:4 -msgid "Public announcement" -msgstr "Publiczne ogłoszenie" +#: mailu/ui/templates/relay/list.html:9 +msgid "New relayed domain" +msgstr "Nowa domena do przekierowania" -#: mailu/ui/templates/announcement.html:8 -msgid "from" -msgstr "od" +#: mailu/ui/templates/token/create.html:4 +msgid "Create an authentication token" +msgstr "Utwórz token uwierzytelniający" -#: mailu/ui/templates/sidebar.html:44 -msgid "Announcement" -msgstr "Ogłoszenie" +#: mailu/ui/templates/token/list.html:12 +msgid "New token" +msgstr "Nowy token" #: mailu/ui/templates/user/create.html:4 msgid "New user" msgstr "Nowy użytkownik" +#: mailu/ui/templates/user/create.html:15 +msgid "General" +msgstr "Ogólne" + +#: mailu/ui/templates/user/create.html:23 +msgid "Features and quotas" +msgstr "Funkcje i limity" + #: mailu/ui/templates/user/edit.html:4 msgid "Edit user" msgstr "Edytuj użytkownika" @@ -505,202 +675,9 @@ msgstr "Zmiana hasła" msgid "Automatic reply" msgstr "Automatyczna odpowiedź" -#: mailu/ui/forms.py:49 -msgid "Maximum user quota" -msgstr "Maksymalny przydział użytkownika" - -#: mailu/ui/forms.py:101 -msgid "Keep a copy of the emails" -msgstr "Przechowuj kopię wiadomości" - -#: mailu/ui/forms.py:163 -msgid "Keep emails on the server" -msgstr "Przechowuj wiadomości na serwerze" - -#: mailu/ui/templates/fetch/list.html:21 -msgid "Keep emails" -msgstr "Przechowuj wiadomości" - -#: mailu/ui/templates/fetch/list.html:35 -msgid "yes" -msgstr "Tak" - -#: mailu/ui/templates/fetch/list.html:35 -msgid "no" -msgstr "Nie" - -#: mailu/ui/forms.py:65 -msgid "Alternative name" -msgstr "Alternatywna nazwa" - -#: mailu/ui/forms.py:70 -msgid "Relayed domain name" -msgstr "" - -#: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18 -msgid "Remote host" -msgstr "Zdalny host" - -#: mailu/ui/templates/sidebar.html:54 -msgid "Relayed domains" -msgstr "" - -#: mailu/ui/templates/alternative/create.html:4 -msgid "Create alternative domain" -msgstr "Utwórz alternatywną domenę" - -#: mailu/ui/templates/alternative/list.html:4 -msgid "Alternative domain list" -msgstr "Alternatywna lista domen" - -#: mailu/ui/templates/alternative/list.html:12 -msgid "Add alternative" -msgstr "Dodaj alternatywę" - -#: mailu/ui/templates/alternative/list.html:19 -msgid "Name" -msgstr "Nazwa" - -#: mailu/ui/templates/domain/list.html:39 -msgid "Alternatives" -msgstr "Alternatywy" - -#: mailu/ui/templates/relay/create.html:4 -msgid "New relay domain" -msgstr "" - -#: mailu/ui/templates/relay/edit.html:4 -msgid "Edit relayd domain" -msgstr "" - -#: mailu/ui/templates/relay/list.html:4 -msgid "Relayed domain list" -msgstr "" - -#: mailu/ui/templates/relay/list.html:9 -msgid "New relayed domain" -msgstr "" - -#: mailu/ui/forms.py:125 -msgid "Your token (write it down, as it will never be displayed again)" -msgstr "Twój token (zapisz go, ponieważ nigdy więcej nie będzie wyświetlany)" - -#: mailu/ui/forms.py:130 mailu/ui/templates/token/list.html:20 -msgid "Authorized IP" -msgstr "Autoryzowany adres IP" - -#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4 -msgid "Authentication tokens" -msgstr "Tokeny uwierzytelnienia" - -#: mailu/ui/templates/sidebar.html:72 -msgid "Go to" -msgstr "Przejdź do" - -#: mailu/ui/templates/sidebar.html:76 -msgid "Webmail" -msgstr "" - -#: mailu/ui/templates/sidebar.html:87 -msgid "Website" -msgstr "Strona internetowa" - -#: mailu/ui/templates/token/create.html:4 -msgid "Create an authentication token" -msgstr "Utwórz token uwierzytelniający" - -#: mailu/ui/templates/token/list.html:12 -msgid "New token" -msgstr "Nowy token" - -#: mailu/ui/templates/user/create.html:15 -msgid "General" -msgstr "" - -#: mailu/ui/templates/user/create.html:22 -msgid "Features and quotas" -msgstr "" - -#: mailu/ui/templates/user/settings.html:14 -msgid "General settings" -msgstr "Ustawienia ogólne" - -#: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15 -msgid "Antispam" -msgstr "Filtr antyspamowy" - -#: mailu/ui/forms.py:99 -msgid "Spam filter tolerance" -msgstr "Tolerancja filtra spamu" - -#: mailu/ui/forms.py:50 -msgid "Enable sign-up" -msgstr "Włącz rejestrację" - -#: mailu/ui/forms.py:57 -msgid "Initial admin" -msgstr "Początkowy administrator" - -#: mailu/ui/forms.py:58 -msgid "Admin password" -msgstr "hasło administratora" - -#: mailu/ui/forms.py:84 -msgid "Enabled" -msgstr "Włączone" - -#: mailu/ui/forms.py:89 -msgid "Email address" -msgstr "Adres e-mail" - -#: mailu/ui/forms.py:93 mailu/ui/templates/sidebar.html:117 -#: mailu/ui/templates/user/signup.html:4 -#: mailu/ui/templates/user/signup_domain.html:4 -msgid "Sign up" -msgstr "" - -#: mailu/ui/forms.py:119 -msgid "End of vacation" -msgstr "Koniec wakacji" - -#: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:82 -msgid "Client setup" -msgstr "Konfiguracja klienta" - -#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43 -msgid "Mail protocol" -msgstr "Protokół poczty" - -#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51 -msgid "Server name" -msgstr "Nazwa serwera" - -#: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:98 -msgid "Register a domain" -msgstr "Zarejestruj domenę" - -#: mailu/ui/templates/domain/details.html:17 -msgid "Generate keys" -msgstr "Wygeneruj klucze" - -#: mailu/ui/templates/domain/signup.html:13 -msgid "In order to register a new domain, you must first setup the\n" -" domain zone so that the domain MX points to this server" -msgstr "" -"Aby zarejestrować nową domenę, musisz najpierw skonfigurować strefę domeny, " -"aby domena MX wskazywała na ten serwer" - -#: mailu/ui/templates/domain/signup.html:18 -msgid "If you do not know how to setup an MX record for your DNS zone,\n" -" please contact your DNS provider or administrator. Also, please wait a\n" -" couple minutes after the MX is set so the local server cache\n" -" expires." -msgstr "" -"Jeśli nie wiesz, jak skonfigurować rekord MX dla swojej " -"strefy DNS,\n" -"skontaktuj się z dostawcą DNS lub administratorem. Proszę również poczekać\n" -"kilka minut po ustawieniu MX , żeby pamięć podręczna serwera " -"lokalnego wygasła." +#: mailu/ui/templates/user/settings.html:26 +msgid "Auto-forward" +msgstr "Automatyczne przekierowanie" #: mailu/ui/templates/user/signup_domain.html:8 msgid "pick a domain for the new account" @@ -713,3 +690,40 @@ msgstr "Domena" #: mailu/ui/templates/user/signup_domain.html:15 msgid "Available slots" msgstr "Dostępne miejsca" + +#~ msgid "Spam filter threshold" +#~ msgstr "Próg filtra antyspamowego" + +#~ msgid "Your account" +#~ msgstr "Twoje konto" + +#~ msgid "Services status" +#~ msgstr "Status usług" + +#~ msgid "Service" +#~ msgstr "Usługa" + +#~ msgid "Status" +#~ msgstr "Status" + +#~ msgid "PID" +#~ msgstr "PID" + +#~ msgid "Image" +#~ msgstr "Obraz" + +#~ msgid "Started" +#~ msgstr "Uruchomione" + +#~ msgid "Last update" +#~ msgstr "Ostatnia aktualizacja" + +#~ msgid "My account" +#~ msgstr "Moje konto" + +#~ msgid "from" +#~ msgstr "od" + +#~ msgid "General settings" +#~ msgstr "Ustawienia ogólne" + diff --git a/core/admin/mailu/translations/pt/LC_MESSAGES/messages.po b/core/admin/mailu/translations/pt/LC_MESSAGES/messages.po index 58338380..f9673767 100644 --- a/core/admin/mailu/translations/pt/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/pt/LC_MESSAGES/messages.po @@ -1,11 +1,16 @@ msgid "" msgstr "" +"Project-Id-Version: Mailu\n" +"PO-Revision-Date: 2021-03-04 18:46+0000\n" +"Last-Translator: Jaume Barber \n" +"Language-Team: Portuguese \n" +"Language: pt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: POEditor.com\n" -"Project-Id-Version: Mailu\n" -"Language: pt\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.0.1\n" #: mailu/ui/forms.py:32 msgid "Invalid email address." @@ -183,7 +188,7 @@ msgstr "Erro no docker" #: mailu/ui/templates/docker-error.html:12 msgid "An error occurred while talking to the Docker server." -msgstr "Um erro foi encontrado na conexão com o servidor Docker" +msgstr "Um erro foi encontrado na conexão com o servidor Docker." #: mailu/admin/templates/login.html:6 msgid "Your account" @@ -700,4 +705,3 @@ msgstr "Domínio" #: mailu/ui/templates/user/signup_domain.html:15 msgid "Available slots" msgstr "Slots disponíveis" - diff --git a/core/admin/mailu/translations/ru/LC_MESSAGES/messages.po b/core/admin/mailu/translations/ru/LC_MESSAGES/messages.po index 72e5f0cb..790119fc 100644 --- a/core/admin/mailu/translations/ru/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/ru/LC_MESSAGES/messages.po @@ -1,8 +1,8 @@ msgid "" msgstr "" "Project-Id-Version: Mailu\n" -"PO-Revision-Date: 2019-07-22 06:23+0000\n" -"Last-Translator: kaiyou \n" +"PO-Revision-Date: 2021-03-04 18:46+0000\n" +"Last-Translator: Jaume Barber \n" "Language-Team: Russian \n" "Language: ru\n" @@ -11,7 +11,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=" "4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" -"X-Generator: Weblate 3.3\n" +"X-Generator: Weblate 4.0.1\n" #: mailu/ui/forms.py:32 msgid "Invalid email address." @@ -189,7 +189,7 @@ msgstr "Ошибка Docker" #: mailu/ui/templates/docker-error.html:12 msgid "An error occurred while talking to the Docker server." -msgstr "Произошла ошибка при обращении к серверу Docker" +msgstr "Произошла ошибка при обращении к серверу Docker." #: mailu/admin/templates/login.html:6 msgid "Your account" diff --git a/core/admin/mailu/translations/sv/LC_MESSAGES/messages.po b/core/admin/mailu/translations/sv/LC_MESSAGES/messages.po index 825888f1..071040f6 100644 --- a/core/admin/mailu/translations/sv/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/sv/LC_MESSAGES/messages.po @@ -1,11 +1,16 @@ msgid "" msgstr "" +"Project-Id-Version: Mailu\n" +"PO-Revision-Date: 2021-03-04 18:46+0000\n" +"Last-Translator: Jaume Barber \n" +"Language-Team: Swedish \n" +"Language: sv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: POEditor.com\n" -"Project-Id-Version: Mailu\n" -"Language: sk\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.0.1\n" #: mailu/ui/forms.py:32 msgid "Invalid email address." @@ -183,7 +188,7 @@ msgstr "Docker fel" #: mailu/ui/templates/docker-error.html:12 msgid "An error occurred while talking to the Docker server." -msgstr "Ett fel inträffade vid kommunikation med Docker" +msgstr "Ett fel inträffade vid kommunikation med Docker." #: mailu/admin/templates/login.html:6 msgid "Your account" @@ -699,4 +704,3 @@ msgstr "" #: mailu/ui/templates/user/signup_domain.html:15 msgid "Available slots" msgstr "" - diff --git a/core/admin/mailu/translations/zh_CN/LC_MESSAGES/messages.po b/core/admin/mailu/translations/zh/LC_MESSAGES/messages.po similarity index 89% rename from core/admin/mailu/translations/zh_CN/LC_MESSAGES/messages.po rename to core/admin/mailu/translations/zh/LC_MESSAGES/messages.po index ee204fec..5543c5e8 100644 --- a/core/admin/mailu/translations/zh_CN/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/zh/LC_MESSAGES/messages.po @@ -3,9 +3,11 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: POEditor.com\n" +"X-Generator: Poedit 1.5.7\n" "Project-Id-Version: Mailu\n" -"Language: zh-CN\n" +"Language: zh\n" +"Last-Translator: Chris Chuan \n" +"Language-Team: \n" #: mailu/ui/forms.py:32 msgid "Invalid email address." @@ -28,7 +30,7 @@ msgstr "密码" #: mailu/ui/forms.py:42 mailu/ui/templates/login.html:4 #: mailu/ui/templates/sidebar.html:111 msgid "Sign in" -msgstr "注册" +msgstr "登录" #: mailu/ui/forms.py:46 mailu/ui/forms.py:56 #: mailu/ui/templates/domain/details.html:27 @@ -44,6 +46,14 @@ msgstr "最大用户数" msgid "Maximum alias count" msgstr "最大别名数" +#: mailu/ui/forms.py:49 +msgid "Maximum user quota" +msgstr "最大用户配额" + +#: mailu/ui/forms.py:50 +msgid "Enable sign-up" +msgstr "启用注册" + #: mailu/ui/forms.py:51 mailu/ui/forms.py:72 mailu/ui/forms.py:83 #: mailu/ui/forms.py:128 mailu/ui/forms.py:140 #: mailu/ui/templates/alias/list.html:21 mailu/ui/templates/domain/list.html:21 @@ -57,10 +67,30 @@ msgstr "说明" msgid "Create" msgstr "创建" +#: mailu/ui/forms.py:57 +msgid "Initial admin" +msgstr "初始管理员" + +#: mailu/ui/forms.py:58 +msgid "Admin password" +msgstr "管理员密码" + #: mailu/ui/forms.py:59 mailu/ui/forms.py:79 mailu/ui/forms.py:91 msgid "Confirm password" msgstr "确认密码" +#: mailu/ui/forms.py:65 +msgid "Alternative name" +msgstr "备用名称" + +#: mailu/ui/forms.py:70 +msgid "Relayed domain name" +msgstr "中继域域名" + +#: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18 +msgid "Remote host" +msgstr "远程主机" + #: mailu/ui/forms.py:80 mailu/ui/templates/user/list.html:22 #: mailu/ui/templates/user/signup_domain.html:16 msgid "Quota" @@ -74,10 +104,24 @@ msgstr "允许IMAP访问" msgid "Allow POP3 access" msgstr "允许POP3访问" +#: mailu/ui/forms.py:84 +msgid "Enabled" +msgstr "启用" + #: mailu/ui/forms.py:85 msgid "Save" msgstr "保存" +#: mailu/ui/forms.py:89 +msgid "Email address" +msgstr "邮件地址" + +#: mailu/ui/forms.py:93 mailu/ui/templates/sidebar.html:117 +#: mailu/ui/templates/user/signup.html:4 +#: mailu/ui/templates/user/signup_domain.html:4 +msgid "Sign up" +msgstr "注册" + #: mailu/ui/forms.py:97 msgid "Displayed name" msgstr "显示名称" @@ -86,10 +130,23 @@ msgstr "显示名称" msgid "Enable spam filter" msgstr "启用垃圾邮件过滤" -#: mailu/ui/forms.py:80 -msgid "Spam filter threshold" +#: mailu/ui/forms.py:99 +msgid "Spam filter tolerance" msgstr "垃圾邮件过滤器阈值" +#: mailu/ui/forms.py:100 +msgid "Enable forwarding" +msgstr "启用转发" + +#: mailu/ui/forms.py:101 +msgid "Keep a copy of the emails" +msgstr "保留电子邮件副本" + +#: mailu/ui/forms.py:103 mailu/ui/forms.py:139 +#: mailu/ui/templates/alias/list.html:20 +msgid "Destination" +msgstr "目的地址" + #: mailu/ui/forms.py:105 msgid "Save settings" msgstr "保存设置" @@ -102,19 +159,6 @@ msgstr "检查密码" msgid "Update password" msgstr "更新密码" -#: mailu/ui/forms.py:100 -msgid "Enable forwarding" -msgstr "启用转发" - -#: mailu/ui/forms.py:103 mailu/ui/forms.py:139 -#: mailu/ui/templates/alias/list.html:20 -msgid "Destination" -msgstr "目的地址" - -#: mailu/ui/forms.py:120 -msgid "Update" -msgstr "更新" - #: mailu/ui/forms.py:115 msgid "Enable automatic reply" msgstr "启用自动回复" @@ -127,6 +171,22 @@ msgstr "回复主题" msgid "Reply body" msgstr "回复正文" +#: mailu/ui/forms.py:119 +msgid "End of vacation" +msgstr "假期结束" + +#: mailu/ui/forms.py:120 +msgid "Update" +msgstr "更新" + +#: mailu/ui/forms.py:125 +msgid "Your token (write it down, as it will never be displayed again)" +msgstr "您的令牌(请记录,它只显示这一次)" + +#: mailu/ui/forms.py:130 mailu/ui/templates/token/list.html:20 +msgid "Authorized IP" +msgstr "授权IP" + #: mailu/ui/forms.py:136 msgid "Alias" msgstr "别名" @@ -169,11 +229,44 @@ msgstr "启用TLS" msgid "Username" msgstr "用户名" +#: mailu/ui/forms.py:163 +msgid "Keep emails on the server" +msgstr "在服务器上保留电子邮件" + +#: mailu/ui/forms.py:168 +msgid "Announcement subject" +msgstr "公告主题" + +#: mailu/ui/forms.py:170 +msgid "Announcement body" +msgstr "公告正文" + +#: mailu/ui/forms.py:172 +msgid "Send" +msgstr "发送" + +#: mailu/ui/templates/announcement.html:4 +msgid "Public announcement" +msgstr "公开公告" + +#: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:82 +msgid "Client setup" +msgstr "客户端设置" + +#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43 +msgid "Mail protocol" +msgstr "邮件协议" + +#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51 +msgid "Server name" +msgstr "服务器名称" + #: mailu/ui/templates/confirm.html:4 msgid "Confirm action" msgstr "确认操作" #: mailu/ui/templates/confirm.html:13 +#, python-format msgid "You are about to %(action)s. Please confirm your action." msgstr "即将%(action)s,请确认您的操作。" @@ -185,54 +278,18 @@ msgstr "Docker错误" msgid "An error occurred while talking to the Docker server." msgstr "Docker服务器通信出错" -#: mailu/admin/templates/login.html:6 -msgid "Your account" -msgstr "你的帐户" - #: mailu/ui/templates/login.html:8 msgid "to access the administration tools" -msgstr "访问管理员工具" - -#: mailu/ui/templates/services.html:4 mailu/ui/templates/sidebar.html:39 -msgid "Services status" -msgstr "服务状态" - -#: mailu/ui/templates/services.html:10 -msgid "Service" -msgstr "服务" - -#: mailu/ui/templates/fetch/list.html:23 mailu/ui/templates/services.html:11 -msgid "Status" -msgstr "状态" - -#: mailu/ui/templates/services.html:12 -msgid "PID" -msgstr "进程ID" - -#: mailu/ui/templates/services.html:13 -msgid "Image" -msgstr "镜像" - -#: mailu/ui/templates/services.html:14 -msgid "Started" -msgstr "已开始" - -#: mailu/ui/templates/services.html:15 -msgid "Last update" -msgstr "最后更新" +msgstr "访问管理工具" #: mailu/ui/templates/sidebar.html:8 msgid "My account" -msgstr "我的帐户" +msgstr "我的账户" #: mailu/ui/templates/sidebar.html:11 mailu/ui/templates/user/list.html:34 msgid "Settings" msgstr "设置" -#: mailu/ui/templates/user/settings.html:22 -msgid "Auto-forward" -msgstr "自动转发" - #: mailu/ui/templates/sidebar.html:21 mailu/ui/templates/user/list.html:35 msgid "Auto-reply" msgstr "自动回复" @@ -240,39 +297,71 @@ msgstr "自动回复" #: mailu/ui/templates/fetch/list.html:4 mailu/ui/templates/sidebar.html:26 #: mailu/ui/templates/user/list.html:36 msgid "Fetched accounts" -msgstr "代收帐户" +msgstr "代收账户" -#: mailu/ui/templates/sidebar.html:105 -msgid "Sign out" -msgstr "登出" +#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4 +msgid "Authentication tokens" +msgstr "认证令牌" #: mailu/ui/templates/sidebar.html:35 msgid "Administration" msgstr "管理" +#: mailu/ui/templates/sidebar.html:44 +msgid "Announcement" +msgstr "公告" + #: mailu/ui/templates/sidebar.html:49 msgid "Administrators" msgstr "管理员" +#: mailu/ui/templates/sidebar.html:54 +msgid "Relayed domains" +msgstr "中继域" + +#: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15 +msgid "Antispam" +msgstr "反垃圾邮件" + #: mailu/ui/templates/sidebar.html:66 msgid "Mail domains" msgstr "邮件域" +#: mailu/ui/templates/sidebar.html:72 +msgid "Go to" +msgstr "转到" + +#: mailu/ui/templates/sidebar.html:76 +msgid "Webmail" +msgstr "网页邮箱" + +#: mailu/ui/templates/sidebar.html:87 +msgid "Website" +msgstr "网站" + #: mailu/ui/templates/sidebar.html:92 msgid "Help" msgstr "帮助" +#: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:98 +msgid "Register a domain" +msgstr "注册域名" + +#: mailu/ui/templates/sidebar.html:105 +msgid "Sign out" +msgstr "登出" + #: mailu/ui/templates/working.html:4 msgid "We are still working on this feature!" msgstr "该功能开发中……" #: mailu/ui/templates/admin/create.html:4 msgid "Add a global administrator" -msgstr "添加超级管理员" +msgstr "添加全局管理员" #: mailu/ui/templates/admin/list.html:4 msgid "Global administrators" -msgstr "超级管理员" +msgstr "全局管理员" #: mailu/ui/templates/admin/list.html:9 msgid "Add administrator" @@ -323,7 +412,7 @@ msgstr "添加别名" #: mailu/ui/templates/relay/list.html:20 mailu/ui/templates/token/list.html:21 #: mailu/ui/templates/user/list.html:24 msgid "Created" -msgstr "创建" +msgstr "已创建" #: mailu/ui/templates/alias/list.html:23 mailu/ui/templates/domain/list.html:23 #: mailu/ui/templates/fetch/list.html:25 mailu/ui/templates/relay/list.html:21 @@ -337,6 +426,22 @@ msgstr "上次编辑" msgid "Edit" msgstr "编辑" +#: mailu/ui/templates/alternative/create.html:4 +msgid "Create alternative domain" +msgstr "创建替代域" + +#: mailu/ui/templates/alternative/list.html:4 +msgid "Alternative domain list" +msgstr "替代域名列表" + +#: mailu/ui/templates/alternative/list.html:12 +msgid "Add alternative" +msgstr "添加替代" + +#: mailu/ui/templates/alternative/list.html:19 +msgid "Name" +msgstr "名称" + #: mailu/ui/templates/domain/create.html:4 #: mailu/ui/templates/domain/list.html:9 msgid "New domain" @@ -344,11 +449,15 @@ msgstr "新域" #: mailu/ui/templates/domain/details.html:4 msgid "Domain details" -msgstr "域详情" +msgstr "域详细信息" #: mailu/ui/templates/domain/details.html:15 msgid "Regenerate keys" -msgstr "重新生成密钥" +msgstr "重新生成秘钥" + +#: mailu/ui/templates/domain/details.html:17 +msgid "Generate keys" +msgstr "生成秘钥" #: mailu/ui/templates/domain/details.html:31 msgid "DNS MX entry" @@ -392,7 +501,7 @@ msgstr "别名数量" #: mailu/ui/templates/domain/list.html:28 msgid "Details" -msgstr "详情" +msgstr "详细信息" #: mailu/ui/templates/domain/list.html:35 msgid "Users" @@ -406,26 +515,60 @@ msgstr "别名" msgid "Managers" msgstr "管理员" +#: mailu/ui/templates/domain/list.html:39 +msgid "Alternatives" +msgstr "备选方案" + +#: mailu/ui/templates/domain/signup.html:13 +msgid "" +"In order to register a new domain, you must first setup the\n" +" domain zone so that the domain MX points to this server" +msgstr "在注册一个新的域名前,您必须先为该域名设置 MX 记录,并使其指向本服务器" + +#: mailu/ui/templates/domain/signup.html:18 +msgid "" +"If you do not know how to setup an MX record for your DNS " +"zone,\n" +" please contact your DNS provider or administrator. Also, please wait " +"a\n" +" couple minutes after the MX is set so the local server " +"cache\n" +" expires." +msgstr "如果您不知道如何为域名设置 MX 记录,请联系你的DNS提供商或者系统管理员。在设置完成 MX 记录后,请等待本地域名服务器的缓存过期。" + + #: mailu/ui/templates/fetch/create.html:4 msgid "Add a fetched account" -msgstr "添加一个代收帐户" +msgstr "添加一个代收账户" #: mailu/ui/templates/fetch/edit.html:4 msgid "Update a fetched account" -msgstr "更新代收帐户" +msgstr "更新代收账户" #: mailu/ui/templates/fetch/list.html:12 msgid "Add an account" -msgstr "添加一个帐户" +msgstr "添加一个账户" #: mailu/ui/templates/fetch/list.html:19 msgid "Endpoint" msgstr "端点" +#: mailu/ui/templates/fetch/list.html:21 +msgid "Keep emails" +msgstr "保留电子邮件" + #: mailu/ui/templates/fetch/list.html:22 msgid "Last check" msgstr "上次检查" +#: mailu/ui/templates/fetch/list.html:35 +msgid "yes" +msgstr "是" + +#: mailu/ui/templates/fetch/list.html:35 +msgid "no" +msgstr "否" + #: mailu/ui/templates/manager/create.html:4 msgid "Add a manager" msgstr "添加一个管理员" @@ -438,41 +581,49 @@ msgstr "管理员列表" msgid "Add manager" msgstr "添加管理员" -#: mailu/ui/forms.py:168 -msgid "Announcement subject" -msgstr "公告主题" +#: mailu/ui/templates/relay/create.html:4 +msgid "New relay domain" +msgstr "新的中继域" -#: mailu/ui/forms.py:170 -msgid "Announcement body" -msgstr "公告正文" +#: mailu/ui/templates/relay/edit.html:4 +msgid "Edit relayd domain" +msgstr "编辑中继域" -#: mailu/ui/forms.py:172 -msgid "Send" -msgstr "发送" +#: mailu/ui/templates/relay/list.html:4 +msgid "Relayed domain list" +msgstr "中继域列表" -#: mailu/ui/templates/announcement.html:4 -msgid "Public announcement" -msgstr "公告" +#: mailu/ui/templates/relay/list.html:9 +msgid "New relayed domain" +msgstr "新的中继域" -#: mailu/ui/templates/announcement.html:8 -msgid "from" -msgstr "来自" +#: mailu/ui/templates/token/create.html:4 +msgid "Create an authentication token" +msgstr "创建一个认证令牌" -#: mailu/ui/templates/sidebar.html:44 -msgid "Announcement" -msgstr "公告" +#: mailu/ui/templates/token/list.html:12 +msgid "New token" +msgstr "新令牌" #: mailu/ui/templates/user/create.html:4 msgid "New user" msgstr "新用户" +#: mailu/ui/templates/user/create.html:15 +msgid "General" +msgstr "通用" + +#: mailu/ui/templates/user/create.html:22 +msgid "Features and quotas" +msgstr "功能和配额" + #: mailu/ui/templates/user/edit.html:4 msgid "Edit user" msgstr "编辑用户" #: mailu/ui/templates/user/forward.html:4 msgid "Forward emails" -msgstr "转发电子邮件" +msgstr "转发邮件" #: mailu/ui/templates/user/list.html:4 msgid "User list" @@ -492,201 +643,15 @@ msgstr "功能" #: mailu/ui/templates/user/password.html:4 msgid "Password update" -msgstr "密码更新" +msgstr "更新密码" #: mailu/ui/templates/user/reply.html:4 msgid "Automatic reply" msgstr "自动回复" -#: mailu/ui/forms.py:49 -msgid "Maximum user quota" -msgstr "最大用户容量" - -#: mailu/ui/forms.py:101 -msgid "Keep a copy of the emails" -msgstr "保留电子邮件副本" - -#: mailu/ui/forms.py:163 -msgid "Keep emails on the server" -msgstr "保留电子邮件在服务器上" - -#: mailu/ui/templates/fetch/list.html:21 -msgid "Keep emails" -msgstr "保存电子邮件" - -#: mailu/ui/templates/fetch/list.html:35 -msgid "yes" -msgstr "是" - -#: mailu/ui/templates/fetch/list.html:35 -msgid "no" -msgstr "否" - -#: mailu/ui/forms.py:65 -msgid "Alternative name" -msgstr "替代名称" - -#: mailu/ui/forms.py:70 -msgid "Relayed domain name" -msgstr "中继域域名" - -#: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18 -msgid "Remote host" -msgstr "远程主机" - -#: mailu/ui/templates/sidebar.html:54 -msgid "Relayed domains" -msgstr "中继域" - -#: mailu/ui/templates/alternative/create.html:4 -msgid "Create alternative domain" -msgstr "创建替代域" - -#: mailu/ui/templates/alternative/list.html:4 -msgid "Alternative domain list" -msgstr "替代域名列表" - -#: mailu/ui/templates/alternative/list.html:12 -msgid "Add alternative" -msgstr "添加替代" - -#: mailu/ui/templates/alternative/list.html:19 -msgid "Name" -msgstr "名称" - -#: mailu/ui/templates/domain/list.html:39 -msgid "Alternatives" -msgstr "备择方案" - -#: mailu/ui/templates/relay/create.html:4 -msgid "New relay domain" -msgstr "新的中继域" - -#: mailu/ui/templates/relay/edit.html:4 -msgid "Edit relayd domain" -msgstr "编辑中继域" - -#: mailu/ui/templates/relay/list.html:4 -msgid "Relayed domain list" -msgstr "中继域列表" - -#: mailu/ui/templates/relay/list.html:9 -msgid "New relayed domain" -msgstr "新的中继域" - -#: mailu/ui/forms.py:125 -msgid "Your token (write it down, as it will never be displayed again)" -msgstr "您的令牌(请记录,它只显示这一次)" - -#: mailu/ui/forms.py:130 mailu/ui/templates/token/list.html:20 -msgid "Authorized IP" -msgstr "授权IP" - -#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/token/list.html:4 -msgid "Authentication tokens" -msgstr "认证令牌" - -#: mailu/ui/templates/sidebar.html:72 -msgid "Go to" -msgstr "转到" - -#: mailu/ui/templates/sidebar.html:76 -msgid "Webmail" -msgstr "网页邮箱" - -#: mailu/ui/templates/sidebar.html:87 -msgid "Website" -msgstr "网站" - -#: mailu/ui/templates/token/create.html:4 -msgid "Create an authentication token" -msgstr "创建一个认证令牌" - -#: mailu/ui/templates/token/list.html:12 -msgid "New token" -msgstr "新的令牌" - -#: mailu/ui/templates/user/create.html:15 -msgid "General" -msgstr "通用" - -#: mailu/ui/templates/user/create.html:22 -msgid "Features and quotas" -msgstr "功能和配额" - -#: mailu/ui/templates/user/settings.html:14 -msgid "General settings" -msgstr "常规设置" - -#: mailu/ui/templates/sidebar.html:59 mailu/ui/templates/user/settings.html:15 -msgid "Antispam" -msgstr "反垃圾邮件" - -#: mailu/ui/forms.py:99 -msgid "Spam filter tolerance" -msgstr "垃圾邮件过滤器容忍度" - -#: mailu/ui/forms.py:50 -msgid "Enable sign-up" -msgstr "启用用户注册" - -#: mailu/ui/forms.py:57 -msgid "Initial admin" -msgstr "初始管理员" - -#: mailu/ui/forms.py:58 -msgid "Admin password" -msgstr "管理员密码" - -#: mailu/ui/forms.py:84 -msgid "Enabled" -msgstr "启用" - -#: mailu/ui/forms.py:89 -msgid "Email address" -msgstr "邮件地址" - -#: mailu/ui/forms.py:93 mailu/ui/templates/sidebar.html:117 -#: mailu/ui/templates/user/signup.html:4 -#: mailu/ui/templates/user/signup_domain.html:4 -msgid "Sign up" -msgstr "注册" - -#: mailu/ui/forms.py:119 -msgid "End of vacation" -msgstr "假期结束" - -#: mailu/ui/templates/client.html:4 mailu/ui/templates/sidebar.html:82 -msgid "Client setup" -msgstr "客户端设置" - -#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:43 -msgid "Mail protocol" -msgstr "邮件协议" - -#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:51 -msgid "Server name" -msgstr "服务器名" - -#: mailu/ui/templates/domain/signup.html:4 mailu/ui/templates/sidebar.html:98 -msgid "Register a domain" -msgstr "注册域名" - -#: mailu/ui/templates/domain/details.html:17 -msgid "Generate keys" -msgstr "生成密钥" - -#: mailu/ui/templates/domain/signup.html:13 -msgid "In order to register a new domain, you must first setup the\n" -" domain zone so that the domain MX points to this server" -msgstr "在注册一个新的域名前,您必须先为该域名设置 MX 记录,并使其指向本服务器" - -#: mailu/ui/templates/domain/signup.html:18 -msgid "If you do not know how to setup an MX record for your DNS zone,\n" -" please contact your DNS provider or administrator. Also, please wait a\n" -" couple minutes after the MX is set so the local server cache\n" -" expires." -msgstr "如果您不知道如何为域名设置 MX 记录,请联系你的DNS提供商或者系统管理员。在设置完成 MX 记录后,请等待本地域名服务器的缓存过期。" +#: mailu/ui/templates/user/settings.html:22 +msgid "Auto-forward" +msgstr "自动转发" #: mailu/ui/templates/user/signup_domain.html:8 msgid "pick a domain for the new account" @@ -700,3 +665,14 @@ msgstr "域名" msgid "Available slots" msgstr "可用" +#~ msgid "Your account" +#~ msgstr "" + +#~ msgid "Spam filter threshold" +#~ msgstr "" + +#~ msgid "from" +#~ msgstr "" + +#~ msgid "General settings" +#~ msgstr "" diff --git a/core/admin/mailu/ui/__init__.py b/core/admin/mailu/ui/__init__.py index ec3601a1..49338cd1 100644 --- a/core/admin/mailu/ui/__init__.py +++ b/core/admin/mailu/ui/__init__.py @@ -1,6 +1,6 @@ from flask import Blueprint -ui = Blueprint('ui', __name__, static_folder='static', template_folder='templates') +ui = Blueprint('ui', __name__, static_folder=None, template_folder='templates') from mailu.ui.views import * diff --git a/core/admin/mailu/ui/forms.py b/core/admin/mailu/ui/forms.py index 356137e8..24d6f899 100644 --- a/core/admin/mailu/ui/forms.py +++ b/core/admin/mailu/ui/forms.py @@ -44,13 +44,6 @@ class MultipleEmailAddressesVerify(object): class ConfirmationForm(flask_wtf.FlaskForm): submit = fields.SubmitField(_('Confirm')) - -class LoginForm(flask_wtf.FlaskForm): - email = fields.StringField(_('E-mail'), [validators.Email()]) - pw = fields.PasswordField(_('Password'), [validators.DataRequired()]) - submit = fields.SubmitField(_('Sign in')) - - class DomainForm(flask_wtf.FlaskForm): name = fields.StringField(_('Domain name'), [validators.DataRequired()]) max_users = fields_.IntegerField(_('Maximum user count'), [validators.NumberRange(min=-1)], default=10) @@ -86,7 +79,7 @@ class UserForm(flask_wtf.FlaskForm): localpart = fields.StringField(_('E-mail'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)]) pw = fields.PasswordField(_('Password')) pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')]) - quota_bytes = fields_.IntegerSliderField(_('Quota'), default=1000000000) + quota_bytes = fields_.IntegerSliderField(_('Quota'), default=10**9) enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True) enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True) displayed_name = fields.StringField(_('Displayed name')) diff --git a/core/admin/mailu/ui/templates/admin/create.html b/core/admin/mailu/ui/templates/admin/create.html index 8d3a7b58..071cb77f 100644 --- a/core/admin/mailu/ui/templates/admin/create.html +++ b/core/admin/mailu/ui/templates/admin/create.html @@ -1,15 +1,15 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Add a global administrator{% endtrans %} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.box() %} +{%- block content %} +{%- call macros.card() %}
{{ form.hidden_tag() }} {{ macros.form_field(form.admin, class_='mailselect') }} {{ macros.form_field(form.submit) }}
-{% endcall %} -{% endblock %} +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/admin/list.html b/core/admin/mailu/ui/templates/admin/list.html index 72b5a1fa..84d954a0 100644 --- a/core/admin/mailu/ui/templates/admin/list.html +++ b/core/admin/mailu/ui/templates/admin/list.html @@ -1,28 +1,32 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Global administrators{% endtrans %} -{% endblock %} +{%- endblock %} -{% block main_action %} - +{%- block main_action %} + {% trans %}Add administrator{% endtrans %} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.table() %} - - {% trans %}Actions{% endtrans %} - {% trans %}Email{% endtrans %} - -{% for admin in admins %} - - - - - {{ admin }} - -{% endfor %} -{% endcall %} -{% endblock %} +{%- block content %} +{%- call macros.table() %} + + + {% trans %}Actions{% endtrans %} + {% trans %}Email{% endtrans %} + + + + {%- for admin in admins %} + + + + + {{ admin }} + + {%- endfor %} + +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alias/create.html b/core/admin/mailu/ui/templates/alias/create.html index 38d7e7e5..ce9f8167 100644 --- a/core/admin/mailu/ui/templates/alias/create.html +++ b/core/admin/mailu/ui/templates/alias/create.html @@ -1,22 +1,22 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Create alias{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ domain }} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.box() %} +{%- block content %} +{%- call macros.card() %}
{{ form.hidden_tag() }} - {{ macros.form_field(form.localpart, append='@'+domain.name+'') }} + {{ macros.form_field(form.localpart, append='@'+domain.name+'') }} {{ macros.form_field(form.wildcard) }} {{ macros.form_field(form.destination, class_='mailselect') }} {{ macros.form_field(form.comment) }} {{ macros.form_field(form.submit) }}
-{% endcall %} -{% endblock %} +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alias/edit.html b/core/admin/mailu/ui/templates/alias/edit.html index b28ea170..4dc13cce 100644 --- a/core/admin/mailu/ui/templates/alias/edit.html +++ b/core/admin/mailu/ui/templates/alias/edit.html @@ -1,9 +1,9 @@ -{% extends "alias/create.html" %} +{%- extends "alias/create.html" %} -{% block title %} +{%- block title %} {% trans %}Edit alias{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ alias }} -{% endblock %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alias/list.html b/core/admin/mailu/ui/templates/alias/list.html index 29766b25..6b52165e 100644 --- a/core/admin/mailu/ui/templates/alias/list.html +++ b/core/admin/mailu/ui/templates/alias/list.html @@ -1,39 +1,43 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Alias list{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ domain.name }} -{% endblock %} +{%- endblock %} -{% block main_action %} -{% trans %}Add alias{% endtrans %} -{% endblock %} +{%- block main_action %} +{% trans %}Add alias{% endtrans %} +{%- endblock %} -{% block content %} -{% call macros.table() %} - - {% trans %}Actions{% endtrans %} - {% trans %}Email{% endtrans %} - {% trans %}Destination{% endtrans %} - {% trans %}Comment{% endtrans %} - {% trans %}Created{% endtrans %} - {% trans %}Last edit{% endtrans %} - -{% for alias in domain.aliases %} - - -   - - - {{ alias }} - {{ alias.destination|join(', ') or '-' }} - {{ alias.comment or '' }} - {{ alias.created_at }} - {{ alias.updated_at or '' }} - -{% endfor %} -{% endcall %} -{% endblock %} +{%- block content %} +{%- call macros.table() %} + + + {% trans %}Actions{% endtrans %} + {% trans %}Email{% endtrans %} + {% trans %}Destination{% endtrans %} + {% trans %}Comment{% endtrans %} + {% trans %}Created{% endtrans %} + {% trans %}Last edit{% endtrans %} + + + + {%- for alias in domain.aliases %} + + +   + + + {{ alias }} + {{ alias.destination|join(', ') or '-' }} + {{ alias.comment or '' }} + {{ alias.created_at | format_date }} + {{ alias.updated_at | format_date }} + + {%- endfor %} + +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alternative/create.html b/core/admin/mailu/ui/templates/alternative/create.html index 75461c67..f10cb718 100644 --- a/core/admin/mailu/ui/templates/alternative/create.html +++ b/core/admin/mailu/ui/templates/alternative/create.html @@ -1,9 +1,9 @@ -{% extends "form.html" %} +{%- extends "form.html" %} -{% block title %} +{%- block title %} {% trans %}Create alternative domain{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ domain }} -{% endblock %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/alternative/list.html b/core/admin/mailu/ui/templates/alternative/list.html index 56e7565b..4ca9f3c8 100644 --- a/core/admin/mailu/ui/templates/alternative/list.html +++ b/core/admin/mailu/ui/templates/alternative/list.html @@ -1,32 +1,38 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Alternative domain list{% endtrans %} -{% endblock %} +{%- endblock %} -{% block subtitle %} +{%- block subtitle %} {{ domain.name }} -{% endblock %} +{%- endblock %} -{% block main_action %} -{% trans %}Add alternative{% endtrans %} -{% endblock %} +{%- block main_action %} +{% trans %}Add alternative{% endtrans %} +{%- endblock %} -{% block content %} -{% call macros.table() %} - - {% trans %}Actions{% endtrans %} - {% trans %}Name{% endtrans %} - {% trans %}Created{% endtrans %} - -{% for alternative in domain.alternatives %} - - - - - {{ alternative }} - {{ alternative.created_at }} - -{% endfor %} -{% endcall %} -{% endblock %} +{%- block content %} +{%- call macros.table() %} + + + {% trans %}Actions{% endtrans %} + {% trans %}Name{% endtrans %} + {% trans %}Created{% endtrans %} + {% trans %}Last edit{% endtrans %} + + + + {%- for alternative in domain.alternatives %} + + + + + {{ alternative }} + {{ alternative.created_at | format_date }} + {{ alternative.updated_at | format_date }} + + {%- endfor %} + +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/announcement.html b/core/admin/mailu/ui/templates/announcement.html index 7dd34d3f..ed7fe772 100644 --- a/core/admin/mailu/ui/templates/announcement.html +++ b/core/admin/mailu/ui/templates/announcement.html @@ -1,16 +1,16 @@ -{% extends "base.html" %} +{%- extends "base.html" %} -{% block title %} +{%- block title %} {% trans %}Public announcement{% endtrans %} -{% endblock %} +{%- endblock %} -{% block content %} -{% call macros.box() %} +{%- block content %} +{%- call macros.card() %}
{{ form.hidden_tag() }} {{ macros.form_field(form.announcement_subject) }} {{ macros.form_field(form.announcement_body, rows=10) }} {{ macros.form_field(form.submit) }}
-{% endcall %} -{% endblock %} +{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/ui/templates/antispam.html b/core/admin/mailu/ui/templates/antispam.html new file mode 100644 index 00000000..0b2713b9 --- /dev/null +++ b/core/admin/mailu/ui/templates/antispam.html @@ -0,0 +1,15 @@ +{%- extends "base.html" %} + +{%- block title %} +{% trans %}Antispam{% endtrans %} +{%- endblock %} + +{%- block subtitle %} +{% trans %}RSPAMD status page{% endtrans %} +{%- endblock %} + +{%- block content %} +
+ +
+{%- endblock %} diff --git a/core/admin/mailu/ui/templates/base.html b/core/admin/mailu/ui/templates/base.html index 8a841e47..e646e579 100644 --- a/core/admin/mailu/ui/templates/base.html +++ b/core/admin/mailu/ui/templates/base.html @@ -1,52 +1,86 @@ -{% import "macros.html" as macros %} +{%- import "macros.html" as macros %} +{%- import "bootstrap/utils.html" as utils %} - + - - - - Mailu-Admin - {{ config["SITENAME"] }} + + + + + Mailu-Admin | {{ config["SITENAME"] }} + + - +
-
- -
-
diff --git a/setup/templates/steps/stack/02_services.html b/setup/templates/steps/stack/02_services.html index 3f5186b0..6fce0ae6 100644 --- a/setup/templates/steps/stack/02_services.html +++ b/setup/templates/steps/stack/02_services.html @@ -55,7 +55,7 @@ the security implications caused by such an increase of attack surface.

Fetchmail allows users to retrieve mail from an external mail-server via IMAP/POP3 and puts it in their inbox. - + diff --git a/tests/build_arm.sh b/tests/build_arm.sh index 04836ddb..32dba421 100755 --- a/tests/build_arm.sh +++ b/tests/build_arm.sh @@ -1,6 +1,6 @@ #!/bin/bash -x -ALPINE_VER="3.10" +ALPINE_VER="3.14" DISTRO="balenalib/rpi-alpine:$ALPINE_VER" # Used for webmails QEMU="arm" diff --git a/tests/compose/core/00_create_users.sh b/tests/compose/core/00_create_users.sh index 49d0511b..f5108302 100755 --- a/tests/compose/core/00_create_users.sh +++ b/tests/compose/core/00_create_users.sh @@ -6,6 +6,6 @@ echo "The above error was intended!" docker-compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu admin admin mailu.io 'FooBar' --mode=ifmissing || exit 1 # Should not fail and update the password; update mode docker-compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu admin admin mailu.io 'password' --mode=update || exit 1 -docker-compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu user user mailu.io 'password' 'SHA512-CRYPT' || exit 1 -docker-compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu user 'user/with/slash' mailu.io 'password' 'SHA512-CRYPT' || exit 1 +docker-compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu user user mailu.io 'password' || exit 1 +docker-compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu user 'user/with/slash' mailu.io 'password' || exit 1 echo "User testing succesfull!" diff --git a/tests/compose/core/02_forward_test.sh b/tests/compose/core/02_forward_test.sh index 595820cf..a53fa459 100755 --- a/tests/compose/core/02_forward_test.sh +++ b/tests/compose/core/02_forward_test.sh @@ -2,7 +2,6 @@ cat << EOF | docker-compose -f tests/compose/core/docker-compose.yml exec -T adm users: - localpart: forwardinguser password_hash: "\$1\$F2OStvi1\$Q8hBIHkdJpJkJn/TrMIZ9/" - hash_scheme: MD5-CRYPT domain: mailu.io forward_enabled: true forward_destination: ["user@mailu.io"] @@ -14,7 +13,6 @@ cat << EOF | docker-compose -f tests/compose/core/docker-compose.yml exec -T adm users: - localpart: forwardinguser password_hash: "\$1\$F2OStvi1\$Q8hBIHkdJpJkJn/TrMIZ9/" - hash_scheme: MD5-CRYPT domain: mailu.io forward_enabled: false forward_destination: [] diff --git a/tests/compose/core/04_reply_test.sh b/tests/compose/core/04_reply_test.sh index 83c114f6..e1479cf0 100755 --- a/tests/compose/core/04_reply_test.sh +++ b/tests/compose/core/04_reply_test.sh @@ -2,7 +2,6 @@ cat << EOF | docker-compose -f tests/compose/core/docker-compose.yml exec -T adm users: - localpart: replyuser password_hash: "\$1\$F2OStvi1\$Q8hBIHkdJpJkJn/TrMIZ9/" - hash_scheme: MD5-CRYPT domain: mailu.io reply_enabled: true reply_subject: This will not reach me @@ -15,7 +14,6 @@ cat << EOF | docker-compose -f tests/compose/core/docker-compose.yml exec -T adm users: - localpart: replyuser password_hash: "\$1\$F2OStvi1\$Q8hBIHkdJpJkJn/TrMIZ9/" - hash_scheme: MD5-CRYPT domain: mailu.io reply_enabled: false EOF diff --git a/tests/compose/core/mailu.env b/tests/compose/core/mailu.env index b13e57c5..254c3c7d 100644 --- a/tests/compose/core/mailu.env +++ b/tests/compose/core/mailu.env @@ -92,7 +92,7 @@ DMARC_RUF=admin # Maildir Compression -# choose compression-method, default: none (value: bz2, gz) +# choose compression-method, default: none (value: gz, bz2, lz4, zstd) COMPRESSION= # change compression-level, default: 6 (value: 1-9) COMPRESSION_LEVEL= @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/fetchmail/mailu.env b/tests/compose/fetchmail/mailu.env index 636a09a9..a015eaa8 100644 --- a/tests/compose/fetchmail/mailu.env +++ b/tests/compose/fetchmail/mailu.env @@ -92,7 +92,7 @@ DMARC_RUF=admin # Maildir Compression -# choose compression-method, default: none (value: bz2, gz) +# choose compression-method, default: none (value: gz, bz2, lz4, zstd) COMPRESSION= # change compression-level, default: 6 (value: 1-9) COMPRESSION_LEVEL= @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/filters/00_create_users.sh b/tests/compose/filters/00_create_users.sh new file mode 100755 index 00000000..3c581685 --- /dev/null +++ b/tests/compose/filters/00_create_users.sh @@ -0,0 +1,5 @@ +echo "Creating user required for next test ..." +# Should not fail and update the password; update mode +docker-compose -f tests/compose/filters/docker-compose.yml exec -T admin flask mailu admin admin mailu.io 'password' --mode=update || exit 1 +docker-compose -f tests/compose/filters/docker-compose.yml exec -T admin flask mailu user user mailu.io 'password' || exit 1 +echo "User created successfully" diff --git a/tests/compose/filters/mailu.env b/tests/compose/filters/mailu.env index b6d5ca8f..1b4fb93d 100644 --- a/tests/compose/filters/mailu.env +++ b/tests/compose/filters/mailu.env @@ -92,7 +92,7 @@ DMARC_RUF=admin # Maildir Compression -# choose compression-method, default: none (value: bz2, gz) +# choose compression-method, default: none (value: gz, bz2, lz4, zstd) COMPRESSION= # change compression-level, default: 6 (value: 1-9) COMPRESSION_LEVEL= @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/rainloop/mailu.env b/tests/compose/rainloop/mailu.env index 9c31c8bb..944dd376 100644 --- a/tests/compose/rainloop/mailu.env +++ b/tests/compose/rainloop/mailu.env @@ -51,7 +51,7 @@ DISABLE_STATISTICS=False ################################### # Expose the admin interface (value: true, false) -ADMIN=true +ADMIN=false # Choose which webmail to run if any (values: roundcube, rainloop, none) WEBMAIL=rainloop @@ -92,7 +92,7 @@ DMARC_RUF=admin # Maildir Compression -# choose compression-method, default: none (value: bz2, gz) +# choose compression-method, default: none (value: gz, bz2, lz4, zstd) COMPRESSION= # change compression-level, default: 6 (value: 1-9) COMPRESSION_LEVEL= @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/roundcube/mailu.env b/tests/compose/roundcube/mailu.env index dc503268..9db7fcbe 100644 --- a/tests/compose/roundcube/mailu.env +++ b/tests/compose/roundcube/mailu.env @@ -51,7 +51,7 @@ DISABLE_STATISTICS=False ################################### # Expose the admin interface (value: true, false) -ADMIN=true +ADMIN=false # Choose which webmail to run if any (values: roundcube, rainloop, none) WEBMAIL=roundcube @@ -92,7 +92,7 @@ DMARC_RUF=admin # Maildir Compression -# choose compression-method, default: none (value: bz2, gz) +# choose compression-method, default: none (value: gz, bz2, lz4, zstd) COMPRESSION= # change compression-level, default: 6 (value: 1-9) COMPRESSION_LEVEL= @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/compose/webdav/mailu.env b/tests/compose/webdav/mailu.env index 90fb25b1..f1de013d 100644 --- a/tests/compose/webdav/mailu.env +++ b/tests/compose/webdav/mailu.env @@ -92,7 +92,7 @@ DMARC_RUF=admin # Maildir Compression -# choose compression-method, default: none (value: bz2, gz) +# choose compression-method, default: none (value: gz, bz2, lz4, zstd) COMPRESSION= # change compression-level, default: 6 (value: 1-9) COMPRESSION_LEVEL= @@ -128,10 +128,6 @@ WEBSITE=https://mailu.io # Docker-compose project name, this will prepended to containers names. COMPOSE_PROJECT_NAME=mailu -# Default password scheme used for newly created accounts and changed passwords -# (value: PBKDF2, BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT) -PASSWORD_SCHEME=PBKDF2 - # Header to take the real ip from REAL_IP_HEADER= diff --git a/tests/deploy.sh b/tests/deploy.sh index 21aec444..abb37b6b 100755 --- a/tests/deploy.sh +++ b/tests/deploy.sh @@ -3,14 +3,5 @@ # Skip deploy for staging branch [ "$TRAVIS_BRANCH" = "staging" ] && exit 0 -# Retag in case of `bors try` -if [ "$TRAVIS_BRANCH" = "testing" ]; then - export DOCKER_ORG="mailutest" - # Commit message is like "Try #99". - # This sets the version tag to "pr-99" - export MAILU_VERSION="pr-${TRAVIS_COMMIT_MESSAGE//[!0-9]/}" - docker-compose -f tests/build.yml build -fi - docker login -u $DOCKER_UN -p $DOCKER_PW docker-compose -f tests/build.yml push diff --git a/towncrier/newsfragments/1031.feature b/towncrier/newsfragments/1031.feature new file mode 100644 index 00000000..5f369262 --- /dev/null +++ b/towncrier/newsfragments/1031.feature @@ -0,0 +1 @@ +Add sending quotas per user diff --git a/towncrier/newsfragments/1096.feature b/towncrier/newsfragments/1096.feature new file mode 100644 index 00000000..f3abd3dc --- /dev/null +++ b/towncrier/newsfragments/1096.feature @@ -0,0 +1 @@ +Allow specific users to send emails from any address using the WILDCARD_SENDERS setting diff --git a/towncrier/newsfragments/1154.enhancement b/towncrier/newsfragments/1154.enhancement new file mode 100644 index 00000000..d19b3d09 --- /dev/null +++ b/towncrier/newsfragments/1154.enhancement @@ -0,0 +1 @@ +Add support for timezones \ No newline at end of file diff --git a/towncrier/newsfragments/116.feature b/towncrier/newsfragments/116.feature new file mode 100644 index 00000000..4f73e7a8 --- /dev/null +++ b/towncrier/newsfragments/116.feature @@ -0,0 +1 @@ + Make the rate limit apply to a subnet rather than a specific IP (/24 for v4 and /56 for v6) diff --git a/towncrier/newsfragments/1194.bugfix b/towncrier/newsfragments/1194.bugfix new file mode 100644 index 00000000..866c6c3b --- /dev/null +++ b/towncrier/newsfragments/1194.bugfix @@ -0,0 +1 @@ +Fix rate-limiting on /webdav/ diff --git a/towncrier/newsfragments/1194.feature b/towncrier/newsfragments/1194.feature new file mode 100644 index 00000000..0cd2a9e9 --- /dev/null +++ b/towncrier/newsfragments/1194.feature @@ -0,0 +1 @@ +Add a credential cache to speedup authentication requests. diff --git a/towncrier/newsfragments/1223.bugfix b/towncrier/newsfragments/1223.bugfix new file mode 100644 index 00000000..3c23d1a4 --- /dev/null +++ b/towncrier/newsfragments/1223.bugfix @@ -0,0 +1,4 @@ +Fixed fetchmail losing track of fetched emails upon container recreation. +The relevant fetchmail files are now retained in the /data folder (in the fetchmail image). +See the docker-compose.yml file for the relevant volume mapping. +If you already had your own mapping, you must double check the volume mapping and take action. diff --git a/towncrier/newsfragments/1294.bugfix b/towncrier/newsfragments/1294.bugfix new file mode 100644 index 00000000..68bb7a8a --- /dev/null +++ b/towncrier/newsfragments/1294.bugfix @@ -0,0 +1 @@ +Ensure that the podop socket is always owned by the postfix user (wasn't the case when build using non-standard base images... typically for arm64) diff --git a/towncrier/newsfragments/1503.doc b/towncrier/newsfragments/1503.doc new file mode 100644 index 00000000..9c59feb7 --- /dev/null +++ b/towncrier/newsfragments/1503.doc @@ -0,0 +1 @@ +Add documentation for Traefik 2 in Reverse Proxy \ No newline at end of file diff --git a/towncrier/newsfragments/1558.feature b/towncrier/newsfragments/1558.feature new file mode 100644 index 00000000..5c4ec30f --- /dev/null +++ b/towncrier/newsfragments/1558.feature @@ -0,0 +1 @@ +Make smtp_tls_policy_maps easily configurable diff --git a/towncrier/newsfragments/1567.feature b/towncrier/newsfragments/1567.feature new file mode 100644 index 00000000..8dc1515a --- /dev/null +++ b/towncrier/newsfragments/1567.feature @@ -0,0 +1 @@ +Implement a language selector for the admin interface. diff --git a/towncrier/newsfragments/1604.feature b/towncrier/newsfragments/1604.feature new file mode 100644 index 00000000..2b47791a --- /dev/null +++ b/towncrier/newsfragments/1604.feature @@ -0,0 +1 @@ +Add cli commands config-import and config-export diff --git a/towncrier/newsfragments/1607.feature b/towncrier/newsfragments/1607.feature new file mode 100644 index 00000000..de9f0895 --- /dev/null +++ b/towncrier/newsfragments/1607.feature @@ -0,0 +1 @@ +Implement SECRET_KEY_FILE and DB_PW_FILE variables for usage with Docker secrets. diff --git a/towncrier/newsfragments/1610.feature b/towncrier/newsfragments/1610.feature new file mode 100644 index 00000000..b56ac332 --- /dev/null +++ b/towncrier/newsfragments/1610.feature @@ -0,0 +1 @@ +Add possibility to enforce inbound STARTTLS via INBOUND_TLS_LEVEL=true diff --git a/towncrier/newsfragments/1612.feature b/towncrier/newsfragments/1612.feature new file mode 100644 index 00000000..04d8d508 --- /dev/null +++ b/towncrier/newsfragments/1612.feature @@ -0,0 +1 @@ +Refactor the rate limiter to ensure that it performs as intented. diff --git a/towncrier/newsfragments/1618.feature b/towncrier/newsfragments/1618.feature new file mode 100644 index 00000000..443f2b5c --- /dev/null +++ b/towncrier/newsfragments/1618.feature @@ -0,0 +1 @@ +Enable OCSP stapling for the http server within nginx. diff --git a/towncrier/newsfragments/1638.fix b/towncrier/newsfragments/1638.fix new file mode 100644 index 00000000..9a87e41e --- /dev/null +++ b/towncrier/newsfragments/1638.fix @@ -0,0 +1 @@ +Hide the login of the user in sent emails diff --git a/towncrier/newsfragments/1662.feature b/towncrier/newsfragments/1662.feature new file mode 100644 index 00000000..f8219757 --- /dev/null +++ b/towncrier/newsfragments/1662.feature @@ -0,0 +1 @@ +Enable support of all hash types passlib supports. diff --git a/towncrier/newsfragments/1686.bugfix b/towncrier/newsfragments/1686.bugfix deleted file mode 100644 index 932d7d7c..00000000 --- a/towncrier/newsfragments/1686.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix letsencrypt access to certbot for the mail-letsencrypt flavour diff --git a/towncrier/newsfragments/1694.feature b/towncrier/newsfragments/1694.feature new file mode 100644 index 00000000..f7e2013e --- /dev/null +++ b/towncrier/newsfragments/1694.feature @@ -0,0 +1 @@ +Support configuring lz4 and zstd compression for dovecot. diff --git a/towncrier/newsfragments/1705.enhancement b/towncrier/newsfragments/1705.enhancement new file mode 100644 index 00000000..9a6cb11a --- /dev/null +++ b/towncrier/newsfragments/1705.enhancement @@ -0,0 +1 @@ +Ensure that RCVD_NO_TLS_LAST doesn't add to the spam score (as TLS usage can't be determined) diff --git a/towncrier/newsfragments/1712.misc b/towncrier/newsfragments/1712.misc new file mode 100644 index 00000000..57c5a3b8 --- /dev/null +++ b/towncrier/newsfragments/1712.misc @@ -0,0 +1 @@ +This adds more details about the postfix-override possibilities (fixes #1628) diff --git a/towncrier/newsfragments/1720.bugfix b/towncrier/newsfragments/1720.bugfix deleted file mode 100644 index 0bf2b8e6..00000000 --- a/towncrier/newsfragments/1720.bugfix +++ /dev/null @@ -1,2 +0,0 @@ -Fix CVE-2020-25275 and CVE-2020-24386 by using alpine 3.13 for -dovecot which contains a fixed dovecot version. diff --git a/towncrier/newsfragments/1753.feature b/towncrier/newsfragments/1753.feature new file mode 100644 index 00000000..09eb834a --- /dev/null +++ b/towncrier/newsfragments/1753.feature @@ -0,0 +1 @@ +Switch to bcrypt_sha256, replace PASSWORD_SCHEME with CREDENTIAL_ROUNDS and dynamically update existing hashes on first login diff --git a/towncrier/newsfragments/1760.bugfix b/towncrier/newsfragments/1760.bugfix new file mode 100644 index 00000000..9d6f38af --- /dev/null +++ b/towncrier/newsfragments/1760.bugfix @@ -0,0 +1,2 @@ +Fix CVE-2021-23240, CVE-2021-3156 and CVE-2021-23239 for postgresql +by force-upgrading sudo. diff --git a/towncrier/newsfragments/1764.feature b/towncrier/newsfragments/1764.feature new file mode 100644 index 00000000..7eb51624 --- /dev/null +++ b/towncrier/newsfragments/1764.feature @@ -0,0 +1 @@ +Implement AdminLTE 3 for the admin interface. diff --git a/towncrier/newsfragments/1798.feature b/towncrier/newsfragments/1798.feature new file mode 100644 index 00000000..125b1767 --- /dev/null +++ b/towncrier/newsfragments/1798.feature @@ -0,0 +1 @@ +Implement MTA-STS and DANE validation. Introduce DEFER_ON_TLS_ERROR (default: True) to harden or loosen the policy enforcement. diff --git a/towncrier/newsfragments/1828.misc b/towncrier/newsfragments/1828.misc new file mode 100644 index 00000000..09da59ad --- /dev/null +++ b/towncrier/newsfragments/1828.misc @@ -0,0 +1 @@ +Switched from Travis to Github actions for CI/CD. Improved CI workflow to perform all tests in parallel. diff --git a/towncrier/newsfragments/1830.misc b/towncrier/newsfragments/1830.misc new file mode 100644 index 00000000..6de3aff1 --- /dev/null +++ b/towncrier/newsfragments/1830.misc @@ -0,0 +1 @@ +Make CI tests run in parallel. diff --git a/towncrier/newsfragments/1831.bugfix b/towncrier/newsfragments/1831.bugfix new file mode 100644 index 00000000..1094be34 --- /dev/null +++ b/towncrier/newsfragments/1831.bugfix @@ -0,0 +1 @@ +Fix roundcube environment configuration for databases \ No newline at end of file diff --git a/towncrier/newsfragments/1851.feature b/towncrier/newsfragments/1851.feature new file mode 100644 index 00000000..e01f5cb4 --- /dev/null +++ b/towncrier/newsfragments/1851.feature @@ -0,0 +1 @@ +Remove cyrus-sasl-plain as it's not packaged by alpine anymore. SASL-login is still available and used when relaying. diff --git a/towncrier/newsfragments/1873.feature b/towncrier/newsfragments/1873.feature new file mode 100644 index 00000000..dacf117e --- /dev/null +++ b/towncrier/newsfragments/1873.feature @@ -0,0 +1 @@ + Hebrew translation has been completed. diff --git a/towncrier/newsfragments/1917.bugfix b/towncrier/newsfragments/1917.bugfix new file mode 100644 index 00000000..68187d61 --- /dev/null +++ b/towncrier/newsfragments/1917.bugfix @@ -0,0 +1 @@ +Alpine has removed support for btree and hash in postfix... please use lmdb instead diff --git a/towncrier/newsfragments/1922.enhancement b/towncrier/newsfragments/1922.enhancement new file mode 100644 index 00000000..4b01fa88 --- /dev/null +++ b/towncrier/newsfragments/1922.enhancement @@ -0,0 +1,5 @@ +Add support for ECDSA certificates when letsencrypt is used. This means dropping compatibility for android < 4.1.1 +Add LETSENCRYPT_SHORTCHAIN to your configuration to avoid sending ISRG Root X1 (this will break compatibility with android < 7.1.1) +Disable AUTH command on port 25 +Disable TLS tickets, reconfigure the cache to improve Forward Secrecy +Prevent clear-text credentials from being sent to relays diff --git a/towncrier/newsfragments/1926.feature b/towncrier/newsfragments/1926.feature new file mode 100644 index 00000000..fdd4ae87 --- /dev/null +++ b/towncrier/newsfragments/1926.feature @@ -0,0 +1 @@ +Log authentication attempts on the admin portal diff --git a/towncrier/newsfragments/1929.enhancement b/towncrier/newsfragments/1929.enhancement new file mode 100644 index 00000000..12c4f607 --- /dev/null +++ b/towncrier/newsfragments/1929.enhancement @@ -0,0 +1,10 @@ +Improved the SSO page. Warning! The new endpoints /sso and /static are introduced. +These endpoints are now used for handling sign on requests and shared static files. +You may want to update your reverse proxy to proxy /sso and /static to Mailu (to the front service). +The example section of using a reverse proxy is updated with this information. + - New SSO page is used for logging in Admin or Webmail. + - Made SSO page available separately. SSO page can now be used without Admin accessible (ADMIN=false). + - Introduced stub /static which is used by all sites for accessing static files. + - Removed the /admin/ prefix to reduce complexity of routing with Mailu. Admin is accessible directly via /admin instead of /admin/ui +Note: Failed logon attempts are logged in the logs of admin. You can watch this with fail2ban. + diff --git a/towncrier/newsfragments/1952.bugfix b/towncrier/newsfragments/1952.bugfix new file mode 100644 index 00000000..655715f1 --- /dev/null +++ b/towncrier/newsfragments/1952.bugfix @@ -0,0 +1,3 @@ +Webmail and Radicale (webdav) were not useable with domains with special characters such as umlauts. +Webmail and radicale now use punycode for logging in. +Punycode was not used in the HTTP headers. This resulted in illegal non-ASCII HTTP headers. diff --git a/towncrier/newsfragments/1960.bugfix b/towncrier/newsfragments/1960.bugfix new file mode 100644 index 00000000..ecf5ac50 --- /dev/null +++ b/towncrier/newsfragments/1960.bugfix @@ -0,0 +1 @@ +Ensure that we do not trust the source-ip address set in headers if REAL_IP_HEADER isn't set. If you are using Mailu behind a reverse proxy, please ensure that you do read the documentation. diff --git a/towncrier/newsfragments/1962.bugfix b/towncrier/newsfragments/1962.bugfix new file mode 100644 index 00000000..70b2aa72 --- /dev/null +++ b/towncrier/newsfragments/1962.bugfix @@ -0,0 +1,5 @@ +Reverse proxy documentation has been updated to reflect new security hardening from PR#1959. +If you do not set the configuration parameters in Mailu what reverse proxy header to trust, +then Mailu will not have access to the real ip address of the connecting client. +This means that rate limiting will not properly work. You can also not use fail2ban. +It is very important to configure this when using a reverse proxy. diff --git a/towncrier/newsfragments/1966.feature b/towncrier/newsfragments/1966.feature new file mode 100644 index 00000000..e379df0b --- /dev/null +++ b/towncrier/newsfragments/1966.feature @@ -0,0 +1,33 @@ +AdminLTE3 design optimizations, asset compression and caching + +- fixed copy of qemu-arm-static for alpine +- added 'set -eu' safeguard +- silenced npm update notification +- added color to webpack call +- changed Admin-LTE default blue +- AdminLTE 3 style tweaks +- localized datatables +- moved external javascript code to vendor.js +- added mailu logo +- moved all inline javascript to app.js +- added iframe display of rspamd page +- updated language-selector to display full language names and use post +- added fieldset to group and en/disable input fields +- added clipboard copy buttons +- cleaned external javascript imports +- pre-split first hostname for further use +- cache dns_* properties of domain object (immutable during runtime) +- fixed and splitted dns_dkim property of domain object (space missing) +- added autoconfig and tlsa properties to domain object +- suppressed extra vertical spacing in jinja2 templates +- improved accessibility for screen reader +- deleted unused/broken /user/forward route +- updated gunicorn to 20.1.0 to get rid of buffering error at startup +- switched webpack to production mode +- added css and javascript minimization +- added pre-compression of assets (gzip) +- removed obsolete dependencies +- switched from node-sass to dart-sass +- changed startup cleaning message from error to info +- move client config to "my account" section when logged in + diff --git a/towncrier/newsfragments/1990.bugfix b/towncrier/newsfragments/1990.bugfix new file mode 100644 index 00000000..394fc05b --- /dev/null +++ b/towncrier/newsfragments/1990.bugfix @@ -0,0 +1 @@ +Fixed roundcube sso login not working. diff --git a/towncrier/newsfragments/1992.enhancement b/towncrier/newsfragments/1992.enhancement new file mode 100644 index 00000000..56a11538 --- /dev/null +++ b/towncrier/newsfragments/1992.enhancement @@ -0,0 +1,3 @@ +Make unbound work with ipv6 +Add a cache-min-ttl of 5minutes +Enable qname minimisation (privacy) diff --git a/towncrier/newsfragments/1996.enhancement b/towncrier/newsfragments/1996.enhancement new file mode 100644 index 00000000..d1bc2ccf --- /dev/null +++ b/towncrier/newsfragments/1996.enhancement @@ -0,0 +1 @@ +Disable the login page if SESSION_COOKIE_SECURE is incompatible with how Mailu is accessed as this seems to be a common misconfiguration. diff --git a/towncrier/newsfragments/2002.enhancement b/towncrier/newsfragments/2002.enhancement new file mode 100644 index 00000000..bd025141 --- /dev/null +++ b/towncrier/newsfragments/2002.enhancement @@ -0,0 +1 @@ +Derive a new subkey (from SECRET_KEY) for SRS diff --git a/towncrier/newsfragments/2007.enhancement b/towncrier/newsfragments/2007.enhancement new file mode 100644 index 00000000..802e6d36 --- /dev/null +++ b/towncrier/newsfragments/2007.enhancement @@ -0,0 +1 @@ +allow sending emails as user+detail@domain.tld diff --git a/towncrier/newsfragments/2017.enhancement b/towncrier/newsfragments/2017.enhancement new file mode 100644 index 00000000..076914d2 --- /dev/null +++ b/towncrier/newsfragments/2017.enhancement @@ -0,0 +1 @@ +rspamd: get dkim keys via REST API instead of filesystem diff --git a/towncrier/newsfragments/224.enhancement b/towncrier/newsfragments/224.enhancement new file mode 100644 index 00000000..9e4edccf --- /dev/null +++ b/towncrier/newsfragments/224.enhancement @@ -0,0 +1 @@ +Document how to setup client autoconfig using an override diff --git a/towncrier/newsfragments/224.feature b/towncrier/newsfragments/224.feature new file mode 100644 index 00000000..9a2f479b --- /dev/null +++ b/towncrier/newsfragments/224.feature @@ -0,0 +1 @@ +Add instructions on how to create DNS records for email client auto-configuration (RFC6186 style) diff --git a/towncrier/newsfragments/360.bugfix b/towncrier/newsfragments/360.bugfix new file mode 100644 index 00000000..d433e0e3 --- /dev/null +++ b/towncrier/newsfragments/360.bugfix @@ -0,0 +1 @@ +RELAYNETS should be a comma separated list of networks diff --git a/towncrier/newsfragments/466.feature b/towncrier/newsfragments/466.feature new file mode 100644 index 00000000..12049b94 --- /dev/null +++ b/towncrier/newsfragments/466.feature @@ -0,0 +1 @@ +Remove the Received header with PRIMARY_HOSTNAME [PUBLIC_IP] diff --git a/towncrier/newsfragments/783.feature b/towncrier/newsfragments/783.feature new file mode 100644 index 00000000..fcafceef --- /dev/null +++ b/towncrier/newsfragments/783.feature @@ -0,0 +1 @@ +Centralize the authentication of webmails behind the admin interface diff --git a/webmails/rainloop/Dockerfile b/webmails/rainloop/Dockerfile index c67c7496..02910e39 100644 --- a/webmails/rainloop/Dockerfile +++ b/webmails/rainloop/Dockerfile @@ -1,50 +1,73 @@ ARG ARCH="" -ARG QEMU=other # NOTE: only add file if building for arm -FROM ${ARCH}php:7.3-apache as build_arm -ONBUILD COPY --from=balenalib/rpi-alpine:3.10 /usr/bin/qemu-arm-static /usr/bin/qemu-arm-static +FROM ${ARCH}alpine:3.14 +ONBUILD COPY --from=balenalib/rpi-alpine:3.14 /usr/bin/qemu-arm-static /usr/bin/qemu-arm-static -FROM ${ARCH}php:7.3-apache as build_other +ENV TZ Etc/UTC -FROM build_${QEMU} -#Shared layer between rainloop and roundcube -RUN apt-get update && apt-get install -y \ - python3 curl python3-pip git python3-multidict \ - && rm -rf /var/lib/apt/lists \ - && echo "ServerSignature Off" >> /etc/apache2/apache2.conf +# Shared later between dovecot postfix nginx rspamd rainloop and roundloop +RUN apk add --no-cache \ + python3 py3-pip tzdata \ + && pip3 install socrate==0.2.0 -# Shared layer between nginx, dovecot, postfix, postgresql, rspamd, unbound, rainloop, roundcube -RUN pip3 install socrate +# https://www.rainloop.net/docs/system-requirements/ +# Rainloop: +# cURL Builtin +# iconv php7-iconv +# json php7-json +# libxml php7-xml +# dom php7-dom +# openssl php7-openssl +# DateTime Builtin +# PCRE Builtin +# SPL Builtin +# Recommended: +# php7-fpm FastCGI Process Manager +# Optional PHP extension (for contacts): +# php7-pdo Accessing databases in PHP +# php7-pdo_sqlite Access to SQLite 3 databases +RUN apk add --no-cache \ + nginx \ + php7 php7-fpm php7-curl php7-iconv php7-json php7-xml php7-dom php7-openssl php7-pdo php7-pdo_sqlite \ + && rm /etc/nginx/http.d/default.conf \ + && rm /etc/php7/php-fpm.d/www.conf \ + && mkdir -p /run/nginx \ + && mkdir -p /var/www/rainloop -ENV RAINLOOP_URL https://github.com/RainLoop/rainloop-webmail/releases/download/v1.14.0/rainloop-community-1.14.0.zip +# nginx / PHP config files +COPY config/nginx-rainloop.conf /etc/nginx/http.d/rainloop.conf +COPY config/php-rainloop.conf /etc/php7/php-fpm.d/rainloop.conf -RUN apt-get update && apt-get install -y \ - unzip python3-jinja2 \ - && rm -rf /var/www/html/ \ - && mkdir /var/www/html \ - && cd /var/www/html \ +# Rainloop login +COPY login/include.php /var/www/rainloop/include.php +COPY login/sso.php /var/www/rainloop/sso.php + +# Parsed en moved at startup +COPY defaults/php.ini /defaults/php.ini +COPY defaults/application.ini /defaults/application.ini +COPY defaults/default.ini /defaults/default.ini + +# Install Rainloop from source +ENV RAINLOOP_URL https://github.com/RainLoop/rainloop-webmail/releases/download/v1.16.0/rainloop-community-1.16.0.zip + +RUN apk add --no-cache \ + curl unzip \ + && cd /var/www/rainloop \ && curl -L -O ${RAINLOOP_URL} \ && unzip -q *.zip \ && rm -f *.zip \ && rm -rf data/ \ && find . -type d -exec chmod 755 {} \; \ && find . -type f -exec chmod 644 {} \; \ - && chown -R www-data: * \ - && apt-get purge -y unzip \ - && rm -rf /var/lib/apt/lists - -COPY include.php /var/www/html/include.php -COPY php.ini /php.ini - -COPY application.ini /application.ini -COPY default.ini /default.ini + && chown -R nginx:nginx /var/www/rainloop \ + && apk del unzip COPY start.py /start.py EXPOSE 80/tcp VOLUME ["/data"] -CMD /start.py +CMD php-fpm7 && /start.py HEALTHCHECK CMD curl -f -L http://localhost/ || exit 1 diff --git a/webmails/rainloop/config/nginx-rainloop.conf b/webmails/rainloop/config/nginx-rainloop.conf new file mode 100644 index 00000000..dfdbf8f7 --- /dev/null +++ b/webmails/rainloop/config/nginx-rainloop.conf @@ -0,0 +1,38 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + + root /var/www/rainloop; + + # /dev/stdout (Default), , off + access_log off; + + # /dev/stderr (Default), , debug, info, notice, warn, error, crit, alert, emerg + error_log /dev/stderr warn; + + index index.php; + + location / { + try_files $uri /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.*)$; + + fastcgi_intercept_errors on; + fastcgi_index index.php; + + fastcgi_keep_conn on; + include /etc/nginx/fastcgi_params; + fastcgi_pass unix:/var/run/php7-fpm.sock; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + location ~ /\.ht { + deny all; + } + + location ^~ /data { + deny all; + } +} diff --git a/webmails/rainloop/config/php-rainloop.conf b/webmails/rainloop/config/php-rainloop.conf new file mode 100644 index 00000000..e9906505 --- /dev/null +++ b/webmails/rainloop/config/php-rainloop.conf @@ -0,0 +1,101 @@ +; Start a new pool named 'rainloop'. +; the variable $pool can be used in any directive and will be replaced by the +; pool name ('rainloop' here) +[rainloop] + +; Redirect worker stdout and stderr into main error log. If not set, stdout and +; stderr will be redirected to /dev/null according to FastCGI specs. +; Default value: no. +catch_workers_output = 1 + +; Unix user/group of processes +; Note: The user is mandatory. If the group is not set, the default user's group +; will be used. +user = nginx +group = nginx + +; The address on which to accept FastCGI requests. +; Valid syntaxes are: +; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on +; a specific port; +; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on +; a specific port; +; 'port' - to listen on a TCP socket to all addresses +; (IPv6 and IPv4-mapped) on a specific port; +; '/path/to/unix/socket' - to listen on a unix socket. +; Note: This value is mandatory. +listen = /var/run/php7-fpm.sock + +; Set permissions for unix socket, if one is used. In Linux, read/write +; permissions must be set in order to allow connections from a web server. Many +; BSD-derived systems allow connections regardless of permissions. +; Default Values: user and group are set as the running user +; mode is set to 0660 +listen.owner = nginx +listen.group = nginx +listen.mode = 0660 + +; Choose how the process manager will control the number of child processes. +; Possible Values: +; static - a fixed number (pm.max_children) of child processes; +; dynamic - the number of child processes are set dynamically based on the +; following directives. With this process management, there will be +; always at least 1 children. +; pm.max_children - the maximum number of children that can +; be alive at the same time. +; pm.start_servers - the number of children created on startup. +; pm.min_spare_servers - the minimum number of children in 'idle' +; state (waiting to process). If the number +; of 'idle' processes is less than this +; number then some children will be created. +; pm.max_spare_servers - the maximum number of children in 'idle' +; state (waiting to process). If the number +; of 'idle' processes is greater than this +; number then some children will be killed. +; ondemand - no children are created at startup. Children will be forked when +; new requests will connect. The following parameter are used: +; pm.max_children - the maximum number of children that +; can be alive at the same time. +; pm.process_idle_timeout - The number of seconds after which +; an idle process will be killed. +; Note: This value is mandatory. +pm = ondemand + +; The number of child processes to be created when pm is set to 'static' and the +; maximum number of child processes when pm is set to 'dynamic' or 'ondemand'. +; This value sets the limit on the number of simultaneous requests that will be +; served. Equivalent to the ApacheMaxClients directive with mpm_prefork. +; Equivalent to the PHP_FCGI_CHILDREN environment variable in the original PHP +; CGI. The below defaults are based on a server without much resources. Don't +; forget to tweak pm.* to fit your needs. +; Note: Used when pm is set to 'static', 'dynamic' or 'ondemand' +; Note: This value is mandatory. +pm.max_children = 5 + +; The number of child processes created on startup. +; Note: Used only when pm is set to 'dynamic' +; Default Value: min_spare_servers + (max_spare_servers - min_spare_servers) / 2 +; pm.start_servers = 2 + +; The desired minimum number of idle server processes. +; Note: Used only when pm is set to 'dynamic' +; Note: Mandatory when pm is set to 'dynamic' +; pm.min_spare_servers = 1 + +; The desired maximum number of idle server processes. +; Note: Used only when pm is set to 'dynamic' +; Note: Mandatory when pm is set to 'dynamic' +; pm.max_spare_servers = 3 + +; This sets the maximum time in seconds a script is allowed to run before it is +; terminated by the parser. This helps prevent poorly written scripts from tying up +; the server. The default setting is 30s. +; Note: Used only when pm is set to 'ondemand' +pm.process_idle_timeout = 10s + +; The number of requests each child process should execute before respawning. +; This can be useful to work around memory leaks in 3rd party libraries. For endless +; request processing specify '0'. +; Equivalent to PHP_FCGI_MAX_REQUESTS. Default value: 0. +; Noted: Used only when pm is set to 'ondemand' +pm.max_requests = 200 diff --git a/webmails/rainloop/application.ini b/webmails/rainloop/defaults/application.ini similarity index 79% rename from webmails/rainloop/application.ini rename to webmails/rainloop/defaults/application.ini index 5bd9943d..d67ec9f0 100644 --- a/webmails/rainloop/application.ini +++ b/webmails/rainloop/defaults/application.ini @@ -8,6 +8,8 @@ allow_admin_panel = Off [labs] allow_gravatar = Off +custom_login_link='sso.php' +custom_logout_link='/sso/logout' [contacts] enable = On diff --git a/webmails/rainloop/default.ini b/webmails/rainloop/defaults/default.ini similarity index 100% rename from webmails/rainloop/default.ini rename to webmails/rainloop/defaults/default.ini diff --git a/webmails/rainloop/php.ini b/webmails/rainloop/defaults/php.ini similarity index 100% rename from webmails/rainloop/php.ini rename to webmails/rainloop/defaults/php.ini diff --git a/webmails/rainloop/include.php b/webmails/rainloop/login/include.php similarity index 100% rename from webmails/rainloop/include.php rename to webmails/rainloop/login/include.php diff --git a/webmails/rainloop/login/sso.php b/webmails/rainloop/login/sso.php new file mode 100644 index 00000000..0bfbe263 --- /dev/null +++ b/webmails/rainloop/login/sso.php @@ -0,0 +1,31 @@ +> /etc/apache2/apache2.conf + && echo "ServerSignature Off\nServerName roundcube" >> /etc/apache2/apache2.conf \ + && sed -i 's,CustomLog.*combined$,\0 "'"expr=!(%{HTTP_USER_AGENT}=='health'\&\&(-R '127.0.0.1/8' || -R '::1'))"'",' /etc/apache2/sites-available/000-default.conf # Shared layer between nginx, dovecot, postfix, postgresql, rspamd, unbound, rainloop, roundcube RUN pip3 install socrate -ENV ROUNDCUBE_URL https://github.com/roundcube/roundcubemail/releases/download/1.4.6/roundcubemail-1.4.6-complete.tar.gz +ENV ROUNDCUBE_URL https://github.com/roundcube/roundcubemail/releases/download/1.4.11/roundcubemail-1.4.11-complete.tar.gz -ENV CARDDAV_URL https://github.com/blind-coder/rcmcarddav/releases/download/v3.0.3/carddav-3.0.3.tar.bz2 +ENV CARDDAV_URL https://github.com/mstilkerich/rcmcarddav/releases/download/v4.1.2/carddav-v4.1.2.tar.gz RUN apt-get update && apt-get install -y \ zlib1g-dev libzip4 libzip-dev libpq-dev \ python3-jinja2 \ gpg \ && docker-php-ext-install zip pdo_mysql pdo_pgsql \ - && echo date.timezone=UTC > /usr/local/etc/php/conf.d/timezone.ini \ && rm -rf /var/www/html/ \ && cd /var/www \ - && curl -L -O ${ROUNDCUBE_URL} \ - && curl -L -O ${CARDDAV_URL} \ - && tar -xf *.tar.gz \ - && tar -xf *.tar.bz2 \ - && rm -f *.tar.gz \ - && rm -f *.tar.bz2 \ + && curl -sL ${ROUNDCUBE_URL} | tar xz \ + && curl -sL ${CARDDAV_URL} | tar xz \ && mv roundcubemail-* html \ && mv carddav html/plugins/ \ && cd html \ - && rm -rf CHANGELOG INSTALL LICENSE README.md UPGRADING composer.json-dist installer \ + && rm -rf CHANGELOG INSTALL LICENSE README.md UPGRADING composer.json-dist installer composer.* \ && sed -i 's,mod_php5.c,mod_php7.c,g' .htaccess \ && sed -i 's,^php_value.*post_max_size,#&,g' .htaccess \ && sed -i 's,^php_value.*upload_max_filesize,#&,g' .htaccess \ - && chown -R www-data: logs temp \ - && rm -rf /var/lib/apt/lists + && ln -sf index.php /var/www/html/sso.php \ + && ln -sf /dev/stderr /var/www/html/logs/errors.log \ + && chown -R root:root . \ + && chown www-data:www-data logs temp \ + && chmod -R a+rX . \ + && rm -rf /var/lib/apt/lists \ + && a2enmod rewrite deflate expires headers COPY php.ini /php.ini COPY config.inc.php /var/www/html/config/ +COPY mailu.php /var/www/html/plugins/mailu/mailu.php COPY start.py /start.py EXPOSE 80/tcp @@ -53,4 +58,4 @@ VOLUME ["/data"] CMD /start.py -HEALTHCHECK CMD curl -f -L http://localhost/ || exit 1 +HEALTHCHECK CMD curl -f -L -H 'User-Agent: health' http://localhost/ || exit 1 diff --git a/webmails/roundcube/config.inc.php b/webmails/roundcube/config.inc.php index eb40047a..99f147fc 100644 --- a/webmails/roundcube/config.inc.php +++ b/webmails/roundcube/config.inc.php @@ -5,7 +5,7 @@ $config = array(); // Generals $config['db_dsnw'] = getenv('DB_DSNW');; $config['temp_dir'] = '/tmp/'; -$config['des_key'] = getenv('SECRET_KEY'); +$config['des_key'] = getenv('SECRET_KEY') ? getenv('SECRET_KEY') : trim(file_get_contents(getenv('SECRET_KEY_FILE'))); $config['cipher_method'] = 'AES-256-CBC'; $config['identities_level'] = 0; $config['reply_all_mode'] = 1; @@ -36,8 +36,12 @@ $config['managesieve_host'] = $imap; $config['managesieve_usetls'] = false; // Customization settings -$config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : ''; +if (filter_var(getenv('ADMIN'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) { + $config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : ''; +} $config['product_name'] = 'Mailu Webmail'; +array_push($config['plugins'], 'mailu'); +$config['sso_logout_url'] = '/sso/logout'; // We access the IMAP and SMTP servers locally with internal names, SSL // will obviously fail but this sounds better than allowing insecure login diff --git a/webmails/roundcube/mailu.php b/webmails/roundcube/mailu.php new file mode 100644 index 00000000..f5079e98 --- /dev/null +++ b/webmails/roundcube/mailu.php @@ -0,0 +1,65 @@ +add_hook('startup', array($this, 'startup')); + $this->add_hook('authenticate', array($this, 'authenticate')); + $this->add_hook('login_after', array($this, 'login')); + $this->add_hook('login_failed', array($this, 'login_failed')); + $this->add_hook('logout_after', array($this, 'logout')); + } + + function startup($args) + { + if (empty($_SESSION['user_id'])) { + $args['action'] = 'login'; + } + + return $args; + } + + function authenticate($args) + { + if (!in_array('HTTP_X_REMOTE_USER', $_SERVER) || !in_array('HTTP_X_REMOTE_USER_TOKEN', $_SERVER)) { + header('HTTP/1.0 403 Forbidden'); + die(); + } + $args['user'] = $_SERVER['HTTP_X_REMOTE_USER']; + $args['pass'] = $_SERVER['HTTP_X_REMOTE_USER_TOKEN']; + + $args['cookiecheck'] = false; + $args['valid'] = true; + + return $args; + } + + function logout($args) { + // Redirect to global SSO logout path. + $this->load_config(); + + $sso_logout_url = rcmail::get_instance()->config->get('sso_logout_url'); + header("Location: " . $sso_logout_url, true); + exit; + } + + function login($args) + { + header('Location: index.php'); + exit(); + } + function login_failed($args) + { + $ua = $_SERVER['HTTP_USER_AGENT']; + $ra = $_SERVER['REMOTE_ADDR']; + if ($ua == 'health' and ($ra == '127.0.0.1' or $ra == '::1')) { + echo "OK"; + exit; + } + header('Location: sso.php'); + exit(); + } + +} diff --git a/webmails/roundcube/php.ini b/webmails/roundcube/php.ini index 27992231..dafa0578 100644 --- a/webmails/roundcube/php.ini +++ b/webmails/roundcube/php.ini @@ -1,5 +1,4 @@ expose_php=Off -date.timezone=UTC +date.timezone={{ TZ }} upload_max_filesize = {{ MAX_FILESIZE }}M post_max_size = {{ MAX_FILESIZE }}M - diff --git a/webmails/roundcube/start.py b/webmails/roundcube/start.py index 649f3324..efaac357 100755 --- a/webmails/roundcube/start.py +++ b/webmails/roundcube/start.py @@ -8,41 +8,38 @@ import subprocess log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "WARNING")) -os.environ["MAX_FILESIZE"] = str(int(int(os.environ.get("MESSAGE_SIZE_LIMIT"))*0.66/1048576)) +os.environ["MAX_FILESIZE"] = str(int(int(os.environ.get("MESSAGE_SIZE_LIMIT")) * 0.66 / 1048576)) -db_flavor=os.environ.get("ROUNDCUBE_DB_FLAVOR",os.environ.get("DB_FLAVOR","sqlite")) -if db_flavor=="sqlite": - os.environ["DB_DSNW"]="sqlite:////data/roundcube.db" -elif db_flavor=="mysql": - os.environ["DB_DSNW"]="mysql://%s:%s@%s/%s" % ( - os.environ.get("ROUNDCUBE_DB_USER","roundcube"), +db_flavor = os.environ.get("ROUNDCUBE_DB_FLAVOR", "sqlite") +if db_flavor == "sqlite": + os.environ["DB_DSNW"] = "sqlite:////data/roundcube.db" +elif db_flavor == "mysql": + os.environ["DB_DSNW"] = "mysql://%s:%s@%s/%s" % ( + os.environ.get("ROUNDCUBE_DB_USER", "roundcube"), os.environ.get("ROUNDCUBE_DB_PW"), - os.environ.get("ROUNDCUBE_DB_HOST",os.environ.get("DB_HOST","database")), - os.environ.get("ROUNDCUBE_DB_NAME","roundcube") - ) -elif db_flavor=="postgresql": - os.environ["DB_DSNW"]="pgsql://%s:%s@%s/%s" % ( - os.environ.get("ROUNDCUBE_DB_USER","roundcube"), + os.environ.get("ROUNDCUBE_DB_HOST", "database"), + os.environ.get("ROUNDCUBE_DB_NAME", "roundcube") + ) +elif db_flavor == "postgresql": + os.environ["DB_DSNW"] = "pgsql://%s:%s@%s/%s" % ( + os.environ.get("ROUNDCUBE_DB_USER", "roundcube"), os.environ.get("ROUNDCUBE_DB_PW"), - os.environ.get("ROUNDCUBE_DB_HOST",os.environ.get("DB_HOST","database")), - os.environ.get("ROUNDCUBE_DB_NAME","roundcube") - ) + os.environ.get("ROUNDCUBE_DB_HOST", "database"), + os.environ.get("ROUNDCUBE_DB_NAME", "roundcube") + ) else: - print("Unknown ROUNDCUBE_DB_FLAVOR: %s",db_flavor) + print("Unknown ROUNDCUBE_DB_FLAVOR: %s", db_flavor) exit(1) - - conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini") # Create dirs, setup permissions -os.system("mkdir -p /data/gpg /var/www/html/logs") -os.system("touch /var/www/html/logs/errors.log") -os.system("chown -R www-data:www-data /var/www/html/logs") +os.system("mkdir -p /data/gpg") try: print("Initializing database") - result=subprocess.check_output(["/var/www/html/bin/initdb.sh","--dir","/var/www/html/SQL"],stderr=subprocess.STDOUT) + result = subprocess.check_output(["/var/www/html/bin/initdb.sh", "--dir", "/var/www/html/SQL"], + stderr=subprocess.STDOUT) print(result.decode()) except subprocess.CalledProcessError as e: if "already exists" in e.stdout.decode(): @@ -53,15 +50,12 @@ except subprocess.CalledProcessError as e: try: print("Upgrading database") - subprocess.check_call(["/var/www/html/bin/update.sh","--version=?","-y"],stderr=subprocess.STDOUT) + subprocess.check_call(["/var/www/html/bin/update.sh", "--version=?", "-y"], stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: quit(1) # Setup database permissions os.system("chown -R www-data:www-data /data") -# Tail roundcube logs -subprocess.Popen(["tail","-f","-n","0","/var/www/html/logs/errors.log"]) - # Run apache os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"])