Compare commits

..

No commits in common. 'dynamic-resolution' and 'master' have entirely different histories.

@ -61,7 +61,7 @@ jobs:
echo "RELEASE=true" >> $GITHUB_ENV echo "RELEASE=true" >> $GITHUB_ENV
echo "DEPLOY=true" >> $GITHUB_ENV echo "DEPLOY=true" >> $GITHUB_ENV
echo "RELEASE=true" >> $GITHUB_ENV echo "RELEASE=true" >> $GITHUB_ENV
- name: Derive PINNED_MAILU_VERSION for master - name: Derive PINNED_MAILU_VERSION for staging for master
if: env.BRANCH == 'master' if: env.BRANCH == 'master'
shell: bash shell: bash
env: env:
@ -76,7 +76,7 @@ jobs:
- derive-variables - derive-variables
uses: ./.github/workflows/build_test_deploy.yml uses: ./.github/workflows/build_test_deploy.yml
with: with:
architecture: 'linux/arm64/v8,linux/arm/v7' architecture: 'linux/arm64,linux/arm/v7'
mailu_version: ${{needs.derive-variables.outputs.MAILU_VERSION}}-arm mailu_version: ${{needs.derive-variables.outputs.MAILU_VERSION}}-arm
pinned_mailu_version: ${{needs.derive-variables.outputs.PINNED_MAILU_VERSION}}-arm pinned_mailu_version: ${{needs.derive-variables.outputs.PINNED_MAILU_VERSION}}-arm
docker_org: ${{needs.derive-variables.outputs.DOCKER_ORG}} docker_org: ${{needs.derive-variables.outputs.DOCKER_ORG}}

@ -58,15 +58,15 @@ on:
required: true required: true
type: string type: string
deploy: deploy:
description: Deploy to docker hub. Happens for all branches but staging. Use string true or false. description: Deploy to docker hub. Happens for all branches but staging
default: true default: true
required: false required: false
type: string type: boolean
release: release:
description: Tag and create the github release. Use string true or false. description: 'Tag and create the github release. Only happens for branch x.y (release branch)'
default: false default: false
required: false required: false
type: string type: boolean
env: env:
HCL_FILE: ./tests/build.hcl HCL_FILE: ./tests/build.hcl
@ -84,123 +84,11 @@ jobs:
- name: Create matrix - name: Create matrix
id: targets id: targets
run: | run: |
echo matrix=$(docker buildx bake -f ${{env.HCL_FILE}} --print | jq -cr '.group.default.targets') >> $GITHUB_OUTPUT echo ::set-output name=matrix::$(docker buildx bake -f ${{env.HCL_FILE}} --print | jq -cr '.group.default.targets')
- name: Show matrix - name: Show matrix
run: | run: |
echo ${{ steps.targets.outputs.matrix }} echo ${{ steps.targets.outputs.matrix }}
## This job builds the base image. The base image is used by all other images.
build-base-image-x64:
name: Build base image x64
if: inputs.architecture == 'linux/amd64'
needs:
- targets
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- name: Retrieve global variables
shell: bash
run: |
echo "BRANCH=${{ inputs.branch }}" >> $GITHUB_ENV
echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV
echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV
echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- uses: crazy-max/ghaction-github-runtime@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.Docker_Login }}
password: ${{ secrets.Docker_Password }}
- name: Helper to convert docker org to lowercase
id: string
uses: ASzc/change-string-case-action@v5
with:
string: ${{ github.repository_owner }}
- name: Build all docker images
env:
DOCKER_ORG: ghcr.io/${{ steps.string.outputs.lowercase }}
MAILU_VERSION: ${{ env.MAILU_VERSION }}
PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }}
uses: docker/bake-action@v2
with:
files: ${{env.HCL_FILE}}
targets: base
load: false
push: false
set: |
*.cache-from=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/base:${{ hashFiles('core/base/Dockerfile','core/base/requirements-prod.txt') }}
*.cache-to=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/base:${{ hashFiles('core/base/Dockerfile','core/base/requirements-prod.txt') }},mode=max
*.platform=${{ inputs.architecture }}
## This job builds the base image. The base image is used by all other images.
build-base-image-arm:
name: Build base image arm
if: inputs.architecture != 'linux/amd64'
needs:
- targets
runs-on: self-hosted
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- name: Retrieve global variables
shell: bash
run: |
echo "BRANCH=${{ inputs.branch }}" >> $GITHUB_ENV
echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV
echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV
echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- uses: crazy-max/ghaction-github-runtime@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.Docker_Login }}
password: ${{ secrets.Docker_Password }}
- name: Helper to convert docker org to lowercase
id: string
uses: ASzc/change-string-case-action@v5
with:
string: ${{ github.repository_owner }}
- name: Build all docker images
env:
DOCKER_ORG: ghcr.io/${{ steps.string.outputs.lowercase }}
MAILU_VERSION: ${{ env.MAILU_VERSION }}
PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }}
uses: docker/bake-action@v2
with:
files: ${{env.HCL_FILE}}
targets: base
load: false
push: false
set: |
*.cache-from=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/base:${{ hashFiles('core/base/Dockerfile','core/base/requirements-prod.txt') }}-arm
*.cache-to=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/base:${{ hashFiles('core/base/Dockerfile','core/base/requirements-prod.txt') }}-arm,mode=max
*.platform=${{ inputs.architecture }}
# This job builds all the images. The build cache is stored in the github actions cache. # This job builds all the images. The build cache is stored in the github actions cache.
# In further jobs, this cache is used to quickly rebuild the images. # In further jobs, this cache is used to quickly rebuild the images.
build: build:
@ -208,7 +96,6 @@ jobs:
if: inputs.architecture == 'linux/amd64' if: inputs.architecture == 'linux/amd64'
needs: needs:
- targets - targets
- build-base-image-x64
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -226,30 +113,26 @@ jobs:
echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV
echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV
echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV
- name: Configure actions/cache@v3 action for storing build cache in the ${{ runner.temp }}/cache folder
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/${{ matrix.target }}
key: ${{ github.ref }}-${{ inputs.mailu_version }}-${{ matrix.target }}-${{ github.run_id }}
restore-keys: |
${{ github.ref }}-${{ inputs.mailu_version }}-${{ matrix.target }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- uses: crazy-max/ghaction-github-runtime@v2 - uses: crazy-max/ghaction-github-runtime@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.Docker_Login }} username: ${{ secrets.Docker_Login }}
password: ${{ secrets.Docker_Password }} password: ${{ secrets.Docker_Password }}
- name: Helper to convert docker org to lowercase
id: string
uses: ASzc/change-string-case-action@v5
with:
string: ${{ github.repository_owner }}
- name: Build all docker images - name: Build all docker images
env: env:
DOCKER_ORG: ghcr.io/${{ steps.string.outputs.lowercase }} DOCKER_ORG: ${{ env.DOCKER_ORG }}
MAILU_VERSION: ${{ env.MAILU_VERSION }} MAILU_VERSION: ${{ env.MAILU_VERSION }}
PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }} PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }}
uses: docker/bake-action@v2 uses: docker/bake-action@v2
@ -257,11 +140,10 @@ jobs:
files: ${{env.HCL_FILE}} files: ${{env.HCL_FILE}}
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
load: false load: false
push: true push: false
set: | set: |
*.cache-from=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/${{ matrix.target }}:buildcache *.cache-from=type=local,src=${{ runner.temp }}/cache/${{ matrix.target }}
*.cache-to=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/${{ matrix.target }}:buildcache,mode=max *.cache-to=type=local,dest=${{ runner.temp }}/cache/${{ matrix.target }},mode=max
*.cache-from=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/base:${{ hashFiles('core/base/Dockerfile','core/base/requirements-prod.txt') }}
*.platform=${{ inputs.architecture }} *.platform=${{ inputs.architecture }}
# This job builds all the images. The build cache is stored in the github actions cache. # This job builds all the images. The build cache is stored in the github actions cache.
@ -271,7 +153,6 @@ jobs:
if: inputs.architecture != 'linux/amd64' if: inputs.architecture != 'linux/amd64'
needs: needs:
- targets - targets
- build-base-image-arm
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -289,30 +170,26 @@ jobs:
echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV
echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV
echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV
- name: Configure actions/cache@v3 action for storing build cache in the ${{ runner.temp }}/cache folder
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/${{ matrix.target }}
key: ${{ github.ref }}-${{ inputs.mailu_version }}-${{ matrix.target }}-${{ github.run_id }}
restore-keys: |
${{ github.ref }}-${{ inputs.mailu_version }}-${{ matrix.target }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- uses: crazy-max/ghaction-github-runtime@v2 - uses: crazy-max/ghaction-github-runtime@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.Docker_Login }} username: ${{ secrets.Docker_Login }}
password: ${{ secrets.Docker_Password }} password: ${{ secrets.Docker_Password }}
- name: Helper to convert docker org to lowercase
id: string
uses: ASzc/change-string-case-action@v5
with:
string: ${{ github.repository_owner }}
- name: Build all docker images - name: Build all docker images
env: env:
DOCKER_ORG: ghcr.io/${{ steps.string.outputs.lowercase }} DOCKER_ORG: ${{ env.DOCKER_ORG }}
MAILU_VERSION: ${{ env.MAILU_VERSION }} MAILU_VERSION: ${{ env.MAILU_VERSION }}
PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }} PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }}
uses: docker/bake-action@v2 uses: docker/bake-action@v2
@ -320,11 +197,10 @@ jobs:
files: ${{env.HCL_FILE}} files: ${{env.HCL_FILE}}
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
load: false load: false
push: true push: false
set: | set: |
*.cache-from=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/${{ matrix.target }}:buildcache-arm *.cache-from=type=local,src=${{ runner.temp }}/cache/${{ matrix.target }}
*.cache-to=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/${{ matrix.target }}:buildcache-arm,mode=max *.cache-to=type=local,dest=${{ runner.temp }}/cache/${{ matrix.target }},mode=max
*.cache-from=type=registry,ref=ghcr.io/${{ steps.string.outputs.lowercase }}/base:${{ hashFiles('core/base/Dockerfile','core/base/requirements-prod.txt') }}-arm
*.platform=${{ inputs.architecture }} *.platform=${{ inputs.architecture }}
# This job runs all the tests. # This job runs all the tests.
@ -336,11 +212,12 @@ jobs:
contents: read contents: read
packages: read packages: read
needs: needs:
- targets
- build - build
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
target: ["core", "fetchmail", "filters", "webmail", "webdav"] target: ["core", "fetchmail", "filters", "snappymail", "roundcube", "webdav"]
time: ["2"] time: ["2"]
include: include:
- target: "filters" - target: "filters"
@ -357,53 +234,76 @@ jobs:
echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV
echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV
echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV
- name: Set up QEMU - name: Configure /cache for image docs
uses: docker/setup-qemu-action@v2 uses: actions/cache@v3
- uses: crazy-max/ghaction-github-runtime@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with: with:
registry: ghcr.io path: ${{ runner.temp }}/cache/docs
username: ${{ github.repository_owner }} key: ${{ github.ref }}-${{ inputs.mailu_version }}-docs-${{ github.run_id }}
password: ${{ secrets.GITHUB_TOKEN }} - name: Configure /cache for image setup
- name: Helper to convert docker org to lowercase uses: actions/cache@v3
id: string
uses: ASzc/change-string-case-action@v5
with: with:
string: ${{ github.repository_owner }} path: ${{ runner.temp }}/cache/setup
- name: Install python packages key: ${{ github.ref }}-${{ inputs.mailu_version }}-setup-${{ github.run_id }}
run: python3 -m pip install -r tests/requirements.txt - name: Configure /cache for image admin
- name: Copy all certs uses: actions/cache@v3
run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*' with:
- name: Test ${{ matrix.target }} path: ${{ runner.temp }}/cache/admin
run: python tests/compose/test.py ${{ matrix.target }} ${{ matrix.time }} key: ${{ github.ref }}-${{ inputs.mailu_version }}-admin-${{ github.run_id }}
env: - name: Configure /cache for image antispam
DOCKER_ORG: ghcr.io/${{ steps.string.outputs.lowercase }} uses: actions/cache@v3
MAILU_VERSION: ${{ env.MAILU_VERSION }} with:
PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }} path: ${{ runner.temp }}/cache/antispam
key: ${{ github.ref }}-${{ inputs.mailu_version }}-antispam-${{ github.run_id }}
deploy: - name: Configure /cache for image front
name: Deploy images uses: actions/cache@v3
# Deploying is not required for staging with:
if: inputs.deploy == 'true' path: ${{ runner.temp }}/cache/front
runs-on: ubuntu-latest key: ${{ github.ref }}-${{ inputs.mailu_version }}-front-${{ github.run_id }}
needs: - name: Configure /cache for image imap
- tests uses: actions/cache@v3
strategy: with:
fail-fast: false path: ${{ runner.temp }}/cache/imap
matrix: key: ${{ github.ref }}-${{ inputs.mailu_version }}-imap-${{ github.run_id }}
target: ["setup", "docs", "fetchmail", "webmail", "admin", "traefik-certdumper", "radicale", "clamav", "rspamd", "postfix", "dovecot", "unbound", "nginx"] - name: Configure /cache for image smtp
steps: uses: actions/cache@v3
- uses: actions/checkout@v3 with:
- name: Retrieve global variables path: ${{ runner.temp }}/cache/smtp
shell: bash key: ${{ github.ref }}-${{ inputs.mailu_version }}-smtp-${{ github.run_id }}
run: | - name: Configure /cache for image snappymail
echo "BRANCH=${{ inputs.branch }}" >> $GITHUB_ENV uses: actions/cache@v3
echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV with:
echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV path: ${{ runner.temp }}/cache/snappymail
echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV key: ${{ github.ref }}-${{ inputs.mailu_version }}-snappymail-${{ github.run_id }}
- name: Configure /cache for image roundcube
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/roundcube
key: ${{ github.ref }}-${{ inputs.mailu_version }}-roundcube-${{ github.run_id }}
- name: Configure /cache for image antivirus
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/antivirus
key: ${{ github.ref }}-${{ inputs.mailu_version }}-antivirus-${{ github.run_id }}
- name: Configure /cache for image fetchmail
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/fetchmail
key: ${{ github.ref }}-${{ inputs.mailu_version }}-fetchmail-${{ github.run_id }}
- name: Configure /cache for image resolver
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/resolver
key: ${{ github.ref }}-${{ inputs.mailu_version }}-resolver-${{ github.run_id }}
- name: Configure /cache for image traefik-certdumper
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/traefik-certdumper
key: ${{ github.ref }}-${{ inputs.mailu_version }}-traefik-certdumper-${{ github.run_id }}
- name: Configure /cache for image webdav
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/webdav
key: ${{ github.ref }}-${{ inputs.mailu_version }}-webdav-${{ github.run_id }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- uses: crazy-max/ghaction-github-runtime@v2 - uses: crazy-max/ghaction-github-runtime@v2
@ -414,20 +314,167 @@ jobs:
with: with:
username: ${{ secrets.Docker_Login }} username: ${{ secrets.Docker_Login }}
password: ${{ secrets.Docker_Password }} password: ${{ secrets.Docker_Password }}
- name: Helper to convert docker org to lowercase - name: Build docker images for testing from cache
id: string env:
uses: ASzc/change-string-case-action@v5 DOCKER_ORG: ${{ env.DOCKER_ORG }}
MAILU_VERSION: ${{ env.MAILU_VERSION }}
PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }}
uses: docker/bake-action@v2
with: with:
string: ${{ github.repository_owner }} files: ${{env.HCL_FILE}}
- name: Push image to Docker load: true
push: false
set: |
*.cache-from=type=local,src=${{ runner.temp }}/cache/docs
*.cache-from=type=local,src=${{ runner.temp }}/cache/setup
*.cache-from=type=local,src=${{ runner.temp }}/cache/admin
*.cache-from=type=local,src=${{ runner.temp }}/cache/antispam
*.cache-from=type=local,src=${{ runner.temp }}/cache/front
*.cache-from=type=local,src=${{ runner.temp }}/cache/imap
*.cache-from=type=local,src=${{ runner.temp }}/cache/smtp
*.cache-from=type=local,src=${{ runner.temp }}/cache/snappymail
*.cache-from=type=local,src=${{ runner.temp }}/cache/roundcube
*.cache-from=type=local,src=${{ runner.temp }}/cache/antivirus
*.cache-from=type=local,src=${{ runner.temp }}/cache/fetchmail
*.cache-from=type=local,src=${{ runner.temp }}/cache/resolver
*.cache-from=type=local,src=${{ runner.temp }}/cache/traefik-certdumper
*.cache-from=type=local,src=${{ runner.temp }}/cache/webdav
*.platform=${{ inputs.architecture }}
- 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 ${{ matrix.target }}
run: python tests/compose/test.py ${{ matrix.target }} ${{ matrix.time }}
env:
DOCKER_ORG: ${{ env.DOCKER_ORG }}
MAILU_VERSION: ${{ env.MAILU_VERSION }}
PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }}
# This job deploys the docker images to the docker repository. The build.hcl file contains logic that determines what tags are pushed.
# E.g. for master only the :master and :latest tags are pushed.
deploy:
name: Deploy images
# Deploying is not required for staging
if: inputs.deploy == 'true'
runs-on: ubuntu-latest
needs:
- build
- tests
steps:
- uses: actions/checkout@v3
- name: Retrieve global variables
shell: bash shell: bash
run: | run: |
if [ '${{ env.MAILU_VERSION }}' == 'master' ]; then pinned_mailu_version='master'; else pinned_mailu_version=${{ env.PINNED_MAILU_VERSION}}; fi; echo "BRANCH=${{ inputs.branch }}" >> $GITHUB_ENV
docker buildx imagetools create \ echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV
--tag ${{ inputs.docker_org }}/${{ matrix.target }}:${{ env.MAILU_VERSION }} \ echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV
--tag ${{ inputs.docker_org }}/${{ matrix.target }}:$pinned_mailu_version \ echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV
--tag ${{ inputs.docker_org }}/${{ matrix.target }}:latest \ - name: Configure /cache for image docs
ghcr.io/${{ steps.string.outputs.lowercase }}/${{ matrix.target }}:${{ env.MAILU_VERSION }} uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/docs
key: ${{ github.ref }}-${{ inputs.mailu_version }}-docs-${{ github.run_id }}
- name: Configure /cache for image setup
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/setup
key: ${{ github.ref }}-${{ inputs.mailu_version }}-setup-${{ github.run_id }}
- name: Configure /cache for image admin
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/admin
key: ${{ github.ref }}-${{ inputs.mailu_version }}-admin-${{ github.run_id }}
- name: Configure /cache for image antispam
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/antispam
key: ${{ github.ref }}-${{ inputs.mailu_version }}-antispam-${{ github.run_id }}
- name: Configure /cache for image front
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/front
key: ${{ github.ref }}-${{ inputs.mailu_version }}-front-${{ github.run_id }}
- name: Configure /cache for image imap
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/imap
key: ${{ github.ref }}-${{ inputs.mailu_version }}-imap-${{ github.run_id }}
- name: Configure /cache for image smtp
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/smtp
key: ${{ github.ref }}-${{ inputs.mailu_version }}-smtp-${{ github.run_id }}
- name: Configure /cache for image snappymail
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/snappymail
key: ${{ github.ref }}-${{ inputs.mailu_version }}-snappymail-${{ github.run_id }}
- name: Configure /cache for image roundcube
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/roundcube
key: ${{ github.ref }}-${{ inputs.mailu_version }}-roundcube-${{ github.run_id }}
- name: Configure /cache for image antivirus
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/antivirus
key: ${{ github.ref }}-${{ inputs.mailu_version }}-antivirus-${{ github.run_id }}
- name: Configure /cache for image fetchmail
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/fetchmail
key: ${{ github.ref }}-${{ inputs.mailu_version }}-fetchmail-${{ github.run_id }}
- name: Configure /cache for image resolver
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/resolver
key: ${{ github.ref }}-${{ inputs.mailu_version }}-resolver-${{ github.run_id }}
- name: Configure /cache for image traefik-certdumper
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/traefik-certdumper
key: ${{ github.ref }}-${{ inputs.mailu_version }}-traefik-certdumper-${{ github.run_id }}
- name: Configure /cache for image webdav
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/webdav
key: ${{ github.ref }}-${{ inputs.mailu_version }}-webdav-${{ github.run_id }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- uses: crazy-max/ghaction-github-runtime@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.Docker_Login }}
password: ${{ secrets.Docker_Password }}
- name: Deploy images to docker hub. Build.hcl contains the logic for the tags that are pushed.
env:
DOCKER_ORG: ${{ env.DOCKER_ORG }}
MAILU_VERSION: ${{ env.MAILU_VERSION }}
PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }}
uses: docker/bake-action@v2
with:
files: ${{env.HCL_FILE}}
push: true
set: |
*.cache-from=type=local,src=${{ runner.temp }}/cache/docs
*.cache-from=type=local,src=${{ runner.temp }}/cache/setup
*.cache-from=type=local,src=${{ runner.temp }}/cache/admin
*.cache-from=type=local,src=${{ runner.temp }}/cache/antispam
*.cache-from=type=local,src=${{ runner.temp }}/cache/front
*.cache-from=type=local,src=${{ runner.temp }}/cache/imap
*.cache-from=type=local,src=${{ runner.temp }}/cache/smtp
*.cache-from=type=local,src=${{ runner.temp }}/cache/snappymail
*.cache-from=type=local,src=${{ runner.temp }}/cache/roundcube
*.cache-from=type=local,src=${{ runner.temp }}/cache/antivirus
*.cache-from=type=local,src=${{ runner.temp }}/cache/fetchmail
*.cache-from=type=local,src=${{ runner.temp }}/cache/resolver
*.cache-from=type=local,src=${{ runner.temp }}/cache/traefik-certdumper
*.cache-from=type=local,src=${{ runner.temp }}/cache/webdav
*.platform=${{ inputs.architecture }}
deploy-arm: deploy-arm:
name: Deploy images for arm name: Deploy images for arm
@ -436,10 +483,6 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
needs: needs:
- build-arm - build-arm
strategy:
fail-fast: false
matrix:
target: ["setup", "docs", "fetchmail", "webmail", "admin", "traefik-certdumper", "radicale", "clamav", "rspamd", "postfix", "dovecot", "unbound", "nginx"]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Retrieve global variables - name: Retrieve global variables
@ -449,6 +492,76 @@ jobs:
echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV echo "MAILU_VERSION=${{ inputs.mailu_version }}" >> $GITHUB_ENV
echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV
echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV
- name: Configure /cache for image docs
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/docs
key: ${{ github.ref }}-${{ inputs.mailu_version }}-docs-${{ github.run_id }}
- name: Configure /cache for image setup
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/setup
key: ${{ github.ref }}-${{ inputs.mailu_version }}-setup-${{ github.run_id }}
- name: Configure /cache for image admin
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/admin
key: ${{ github.ref }}-${{ inputs.mailu_version }}-admin-${{ github.run_id }}
- name: Configure /cache for image antispam
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/antispam
key: ${{ github.ref }}-${{ inputs.mailu_version }}-antispam-${{ github.run_id }}
- name: Configure /cache for image front
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/front
key: ${{ github.ref }}-${{ inputs.mailu_version }}-front-${{ github.run_id }}
- name: Configure /cache for image imap
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/imap
key: ${{ github.ref }}-${{ inputs.mailu_version }}-imap-${{ github.run_id }}
- name: Configure /cache for image smtp
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/smtp
key: ${{ github.ref }}-${{ inputs.mailu_version }}-smtp-${{ github.run_id }}
- name: Configure /cache for image snappymail
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/snappymail
key: ${{ github.ref }}-${{ inputs.mailu_version }}-snappymail-${{ github.run_id }}
- name: Configure /cache for image roundcube
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/roundcube
key: ${{ github.ref }}-${{ inputs.mailu_version }}-roundcube-${{ github.run_id }}
- name: Configure /cache for image antivirus
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/antivirus
key: ${{ github.ref }}-${{ inputs.mailu_version }}-antivirus-${{ github.run_id }}
- name: Configure /cache for image fetchmail
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/fetchmail
key: ${{ github.ref }}-${{ inputs.mailu_version }}-fetchmail-${{ github.run_id }}
- name: Configure /cache for image resolver
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/resolver
key: ${{ github.ref }}-${{ inputs.mailu_version }}-resolver-${{ github.run_id }}
- name: Configure /cache for image traefik-certdumper
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/traefik-certdumper
key: ${{ github.ref }}-${{ inputs.mailu_version }}-traefik-certdumper-${{ github.run_id }}
- name: Configure /cache for image webdav
uses: actions/cache@v3
with:
path: ${{ runner.temp }}/cache/webdav
key: ${{ github.ref }}-${{ inputs.mailu_version }}-webdav-${{ github.run_id }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- uses: crazy-max/ghaction-github-runtime@v2 - uses: crazy-max/ghaction-github-runtime@v2
@ -459,20 +572,31 @@ jobs:
with: with:
username: ${{ secrets.Docker_Login }} username: ${{ secrets.Docker_Login }}
password: ${{ secrets.Docker_Password }} password: ${{ secrets.Docker_Password }}
- name: Helper to convert docker org to lowercase - name: Deploy images to docker hub. Build.hcl contains the logic for the tags that are pushed.
id: string env:
uses: ASzc/change-string-case-action@v5 DOCKER_ORG: ${{ env.DOCKER_ORG }}
MAILU_VERSION: ${{ env.MAILU_VERSION }}
PINNED_MAILU_VERSION: ${{ env.PINNED_MAILU_VERSION }}
uses: docker/bake-action@v2
with: with:
string: ${{ github.repository_owner }} files: ${{env.HCL_FILE}}
- name: Push image to Docker push: true
shell: bash set: |
run: | *.cache-from=type=local,src=${{ runner.temp }}/cache/docs
if [ '${{ env.MAILU_VERSION }}' == 'master-arm' ]; then pinned_mailu_version='master-arm'; else pinned_mailu_version=${{ env.PINNED_MAILU_VERSION}}; fi; *.cache-from=type=local,src=${{ runner.temp }}/cache/setup
docker buildx imagetools create \ *.cache-from=type=local,src=${{ runner.temp }}/cache/admin
--tag ${{ inputs.docker_org }}/${{ matrix.target }}:${{ env.MAILU_VERSION }} \ *.cache-from=type=local,src=${{ runner.temp }}/cache/antispam
--tag ${{ inputs.docker_org }}/${{ matrix.target }}:$pinned_mailu_version \ *.cache-from=type=local,src=${{ runner.temp }}/cache/front
--tag ${{ inputs.docker_org }}/${{ matrix.target }}:latest \ *.cache-from=type=local,src=${{ runner.temp }}/cache/imap
ghcr.io/${{ steps.string.outputs.lowercase }}/${{ matrix.target }}:${{ env.MAILU_VERSION }} *.cache-from=type=local,src=${{ runner.temp }}/cache/smtp
*.cache-from=type=local,src=${{ runner.temp }}/cache/snappymail
*.cache-from=type=local,src=${{ runner.temp }}/cache/roundcube
*.cache-from=type=local,src=${{ runner.temp }}/cache/antivirus
*.cache-from=type=local,src=${{ runner.temp }}/cache/fetchmail
*.cache-from=type=local,src=${{ runner.temp }}/cache/resolver
*.cache-from=type=local,src=${{ runner.temp }}/cache/traefik-certdumper
*.cache-from=type=local,src=${{ runner.temp }}/cache/webdav
*.platform=${{ inputs.architecture }}
#This job creates a tagged release. A tag is created for the pinned version x.y.z. The GH release refers to this tag. #This job creates a tagged release. A tag is created for the pinned version x.y.z. The GH release refers to this tag.
tag-release: tag-release:

@ -82,7 +82,7 @@ jobs:
echo "PINNED_MAILU_VERSION=staging" >> $GITHUB_ENV echo "PINNED_MAILU_VERSION=staging" >> $GITHUB_ENV
echo "DEPLOY=false" >> $GITHUB_ENV echo "DEPLOY=false" >> $GITHUB_ENV
echo "RELEASE=false" >> $GITHUB_ENV echo "RELEASE=false" >> $GITHUB_ENV
- name: Derive PINNED_MAILU_VERSION for master - name: Derive PINNED_MAILU_VERSION for staging for master
if: env.BRANCH == 'master' if: env.BRANCH == 'master'
shell: bash shell: bash
env: env:

4
.gitignore vendored

@ -1,5 +1,5 @@
**/*.pyc *.pyc
**/*.mo *.mo
__pycache__ __pycache__
pip-selfcheck.json pip-selfcheck.json
/core/admin/lib* /core/admin/lib*

@ -4,4 +4,4 @@ This project is open source, and your contributions are all welcome. There are m
2. contribute code and/or configuration to the repository (see [the development guidelines](https://mailu.io/master/contributors/workflow.html) for details); 2. contribute code and/or configuration to the repository (see [the development guidelines](https://mailu.io/master/contributors/workflow.html) for details);
3. contribute localization to your native language (see [the localization docs](https://mailu.io/master/contributors/localization.html) for details); 3. contribute localization to your native language (see [the localization docs](https://mailu.io/master/contributors/localization.html) for details);
Either way, keep in mind that the code you write or the translation you produce must be licensed under the same conditions as the project itself. Additionally, all contributors are considered equal co-authors of the project. Either way, keep in mind that the code you write or the translation you produce muts be licensed under the same conditions as the project itself. Additionally, all contributors are considered equal co-authors of the project.

@ -1,71 +1,44 @@
<!--
Thank you for opening an issue with Mailu. Please understand that issues are meant for bugs and enhancement-requests. Thank you for opening an issue with Mailu. Please understand that issues are meant for bugs and enhancement-requests.
For **user-support questions**, reach out to us on [matrix](https://matrix.to/#/#mailu:tedomum.net). For **user-support questions**, reach out to us on [matrix](https://matrix.to/#/#mailu:tedomum.net).
To be able to help you best, we need some more information. To be able to help you best, we need some more information.
Before you open your issue ## Before you open your issue
- Check if no issue or pull-request for this already exists. - [ ] Check if no issue or pull-request for this already exists.
- Check [documentation](https://mailu.io/master/) and [FAQ](https://mailu.io/master/faq.html). (Tip, use the search function on the documentation page) - [ ] Check [documentation](https://mailu.io/master/) and [FAQ](https://mailu.io/master/faq.html). (Tip, use the search function on the documentation page)
- You understand `Mailu` is made by volunteers in their **free time** — be concise, civil and accept that delays can occur. - [ ] You understand `Mailu` is made by volunteers in their **free time** — be conscise, civil and accept that delays can occur.
- The title of the issue should be short and simple. It should contain specific terms related to the actual issue. Be specific while writing the title. - [ ] The title of the issue should be short and simple. It should contain specific terms related to the actual issue. Be specific while writing the title.
Please put your text outside of the comment blocks to be visible. You can use the button "Preview" above to check.
-->
## Environment & Version
## Environment & Versions
### Environment ### Environment
- [ ] docker-compose
- [ ] kubernetes
- [ ] docker swarm
- [ ] docker-compose ### Versions
- [ ] kubernetes
- [ ] docker swarm
### Version
- Version: `master`
<!--
To find your version, get the image name of a mailu container and read the version from the tag (example for version 1.7). To find your version, get the image name of a mailu container and read the version from the tag (example for version 1.7).
```
$> docker ps -a | grep mailu $> docker ps -a | grep mailu
140b09d4b09c mailu/roundcube:1.7 "docker-php-entrypoi…" 2 weeks ago Up 2 days (healthy) 80/tcp 140b09d4b09c mailu/roundcube:1.7 "docker-php-entrypoi…" 2 weeks ago Up 2 days (healthy) 80/tcp
$> grep MAILU_VERSION docker-compose.yml mailu.env $> grep MAILU_VERSION docker-compose.yml mailu.env
--> ```
## Description ## Description
<!--
Further explain the bug in a few words. It should be clear what the unexpected behaviour is. Share it in an easy-to-understand language. Further explain the bug in a few words. It should be clear what the unexpected behaviour is. Share it in an easy-to-understand language.
-->
## Replication Steps ## Replication Steps
<!--
Steps for replicating your issue Steps for replicating your issue
-->
## Observed behaviour
<!--
Explain or paste the result you received.
-->
## Expected behaviour ## Expected behaviour
<!-- Explain what results you expected - be as specific as possible. Just saying "it doesnt work as expected" is not useful. It's also helpful to describe what you actually experienced.
Explain what results you expected - be as specific as possible.
Just saying "it doesnt work as expected" is not useful. It's also helpful to describe what you actually experienced.
-->
## Logs ## Logs
<!-- Often it is very useful to include log fragments of the involved component. You can get the logs via `docker logs <container name> --tail 1000`. For example for the admin container:
Often it is very useful to include log fragments of the involved component. `docker logs mailu_admin_1 --tail 1000`
You can get the logs via `docker logs <container name> --tail 1000`.
For example for the admin container: `docker logs mailu_admin_1 --tail 1000`
or using docker-compose `docker-compose -f /mailu/docker-compose.yml logs --tail 1000 admin` or using docker-compose `docker-compose -f /mailu/docker-compose.yml logs --tail 1000 admin`
If you can find the relevant section, please share only the parts that seem relevant. If you have any logs, please enclose them in code tags, like so: If you can find the relevant section, please share only the parts that seem relevant. If you have any logs, please enclose them in code tags, like so:
````markdown
``` ```
Your logs here! Your logs here!
``` ```
--> ````

@ -22,8 +22,8 @@ Main features include:
- **Web access**, multiple Webmails and administration interface - **Web access**, multiple Webmails and administration interface
- **User features**, aliases, auto-reply, auto-forward, fetched accounts - **User features**, aliases, auto-reply, auto-forward, fetched accounts
- **Admin features**, global admins, announcements, per-domain delegation, quotas - **Admin features**, global admins, announcements, per-domain delegation, quotas
- **Security**, enforced TLS, DANE, MTA-STS, Letsencrypt!, outgoing DKIM, anti-virus scanner, [Snuffleupagus](https://github.com/jvoisin/snuffleupagus/) - **Security**, enforced TLS, DANE, MTA-STS, Letsencrypt!, outgoing DKIM, anti-virus scanner
- **Antispam**, auto-learn, greylisting, DMARC and SPF, anti-spoofing - **Antispam**, auto-learn, greylisting, DMARC and SPF
- **Freedom**, all FOSS components, no tracker included - **Freedom**, all FOSS components, no tracker included
![Domains](docs/assets/screenshots/domains.png) ![Domains](docs/assets/screenshots/domains.png)

@ -2,4 +2,3 @@
lib64 lib64
.vscode .vscode
tags tags
dev

@ -1,31 +1,61 @@
# syntax=docker/dockerfile-upstream:1.4.3 # First stage to build assets
ARG DISTRO=alpine:3.14.5
# admin image FROM node:16-alpine3.16 as assets
FROM base
COPY package.json ./
RUN set -eu \
&& npm config set update-notifier false \
&& npm install --no-fund
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
ARG VERSION
ENV TZ Etc/UTC
ARG VERSION=local
LABEL version=$VERSION LABEL version=$VERSION
RUN set -euxo pipefail \ # python3 shared with most images
; apk add --no-cache libressl mariadb-connector-c postgresql-libs RUN set -eu \
&& apk add --no-cache python3 py3-pip py3-wheel git bash tzdata \
&& pip3 install --upgrade pip
COPY --from=assets /work/static/ ./mailu/static/ RUN mkdir -p /app
WORKDIR /app
COPY audit.py / COPY requirements-prod.txt requirements.txt
COPY start.py / RUN set -eu \
&& apk add --no-cache libressl curl postgresql-libs mariadb-connector-c \
&& pip install --no-cache-dir -r requirements.txt --only-binary=:all: --no-binary=Flask-bootstrap,PyYAML,SQLAlchemy \
|| ( 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 migrations/ ./migrations/ COPY --from=assets static ./mailu/static
COPY mailu ./mailu
COPY migrations ./migrations
COPY start.py /start.py
COPY audit.py /audit.py
COPY mailu/ ./mailu/ RUN pybabel compile -d mailu/translations
RUN set -euxo pipefail \
; venv/bin/pybabel compile -d mailu/translations
RUN echo $VERSION >/version
EXPOSE 80/tcp EXPOSE 80/tcp
HEALTHCHECK CMD curl -skfLo /dev/null http://localhost/sso/login?next=ui.index
VOLUME ["/data","/dkim"] VOLUME ["/data","/dkim"]
ENV FLASK_APP mailu
ENV FLASK_APP=mailu
CMD /start.py CMD /start.py
HEALTHCHECK CMD curl -f -L http://localhost/sso/login?next=ui.index || exit 1
RUN echo $VERSION >> /version

@ -1,22 +0,0 @@
# syntax=docker/dockerfile-upstream:1.4.3
FROM node:16-alpine3.16
WORKDIR /work
COPY package.json ./
RUN set -euxo pipefail \
; npm config set update-notifier false \
; npm install --no-audit --no-fund \
; sed -i 's/#007bff/#55a5d9/' node_modules/admin-lte/build/scss/_bootstrap-variables.scss \
; mkdir assets \
; 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
COPY assets/ ./assets/
COPY webpack.config.js ./
RUN set -euxo pipefail \
; node_modules/.bin/webpack-cli --color

@ -0,0 +1,79 @@
require('./app.css');
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: [',', ' '],
});
// 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);
}
});

@ -1,136 +0,0 @@
// Inspired from https://github.com/mehdibo/hibp-js/blob/master/hibp.js
function sha1(string) {
var buffer = new TextEncoder("utf-8").encode(string);
return crypto.subtle.digest("SHA-1", buffer).then(function (buffer) {
// Get the hex code
var hexCodes = [];
var view = new DataView(buffer);
for (var i = 0; i < view.byteLength; i += 4) {
// Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
var value = view.getUint32(i);
// toString(16) will give the hex representation of the number without padding
var stringValue = value.toString(16);
// We use concatenation and slice for padding
var padding = '00000000';
var paddedValue = (padding + stringValue).slice(-padding.length);
hexCodes.push(paddedValue);
}
// Join all the hex strings into one
return hexCodes.join("");
});
}
function hibpCheck(pwd) {
// We hash the pwd first
sha1(pwd).then(function(hash){
// We send the first 5 chars of the hash to hibp's API
const req = new XMLHttpRequest();
req.open('GET', 'https://api.pwnedpasswords.com/range/'+hash.substr(0, 5));
req.setRequestHeader('Add-Padding', 'true');
req.addEventListener("load", function(){
// When we get back a response from the server
// We create an array of lines and loop through them
const lines = this.responseText.split("\n");
const hashSub = hash.slice(5).toUpperCase();
for (var i in lines){
// Check if the line matches the rest of the hash
if (lines[i].substring(0, 35) == hashSub){
const val = parseInt(lines[i].trimEnd("\r").split(":")[1]);
if (val > 0) {
$("#pwned").val(val);
}
return; // If found no need to continue the loop
}
}
$("#pwned").val(0);
});
req.send();
});
}
// 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: [',', ' '],
});
// 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);
}
if (window.isSecureContext) {
$("#pw").on("change paste", function(){
hibpCheck($(this).val());
return true;
});
$("#pw").closest("form").submit(function(event){
if (parseInt($("#pwned").val()) < 0) {
hibpCheck($("#pw").val());
}
});
}
});

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

@ -1,5 +1,5 @@
// AdminLTE // AdminLTE
window.$ = window.jQuery = require('admin-lte/plugins/jquery/jquery.min.js'); import 'admin-lte/plugins/jquery/jquery.min.js';
import 'admin-lte/plugins/bootstrap/js/bootstrap.bundle.min.js'; import 'admin-lte/plugins/bootstrap/js/bootstrap.bundle.min.js';
import 'admin-lte/build/scss/adminlte.scss'; import 'admin-lte/build/scss/adminlte.scss';
import 'admin-lte/build/js/AdminLTE.js'; import 'admin-lte/build/js/AdminLTE.js';
@ -18,7 +18,7 @@ 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-bs4/js/dataTables.bootstrap4.min.js';
import 'admin-lte/plugins/datatables-responsive/js/dataTables.responsive.min.js'; import 'admin-lte/plugins/datatables-responsive/js/dataTables.responsive.min.js';
import 'admin-lte/plugins/datatables-responsive/js/responsive.bootstrap4.min.js'; import 'admin-lte/plugins/datatables-responsive/js/responsive.bootstrap4.min.js';
import modules from "./*.json";
// clipboard.js // clipboard.js
window.ClipboardJS = require('clipboard/dist/clipboard.min.js'); import 'clipboard/dist/clipboard.min.js';

@ -1,4 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/python3
import sys import sys
import tabulate import tabulate

@ -44,10 +44,8 @@ def create_app_from_config(config):
# Initialize debugging tools # Initialize debugging tools
if app.config.get("DEBUG"): if app.config.get("DEBUG"):
debug.toolbar.init_app(app) debug.toolbar.init_app(app)
if app.config.get("DEBUG_PROFILER"): # TODO: add a specific configuration variable for profiling
debug.profiler.init_app(app) # debug.profiler.init_app(app)
if assets := app.config.get('DEBUG_ASSETS'):
app.static_folder = assets
# Inject the default variables in the Jinja parser # Inject the default variables in the Jinja parser
# TODO: move this to blueprints when needed # TODO: move this to blueprints when needed
@ -57,7 +55,6 @@ def create_app_from_config(config):
return dict( return dict(
signup_domains= signup_domains, signup_domains= signup_domains,
config = app.config, config = app.config,
get_locale = utils.get_locale,
) )
# Jinja filters # Jinja filters

@ -1,6 +1,7 @@
import os import os
from datetime import timedelta from datetime import timedelta
from socrate import system
import ipaddress import ipaddress
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
@ -10,21 +11,18 @@ DEFAULT_CONFIG = {
'BABEL_DEFAULT_TIMEZONE': 'UTC', 'BABEL_DEFAULT_TIMEZONE': 'UTC',
'BOOTSTRAP_SERVE_LOCAL': True, 'BOOTSTRAP_SERVE_LOCAL': True,
'RATELIMIT_STORAGE_URL': '', 'RATELIMIT_STORAGE_URL': '',
'QUOTA_STORAGE_URL': '',
'DEBUG': False, 'DEBUG': False,
'DEBUG_PROFILER': False,
'DEBUG_TB_INTERCEPT_REDIRECTS': False,
'DEBUG_ASSETS': '',
'DOMAIN_REGISTRATION': False, 'DOMAIN_REGISTRATION': False,
'TEMPLATES_AUTO_RELOAD': True, 'TEMPLATES_AUTO_RELOAD': True,
'MEMORY_SESSIONS': False, 'MEMORY_SESSIONS': False,
'FETCHMAIL_ENABLED': False,
# Database settings # Database settings
'DB_FLAVOR': None, 'DB_FLAVOR': None,
'DB_USER': 'mailu', 'DB_USER': 'mailu',
'DB_PW': None, 'DB_PW': None,
'DB_HOST': 'database', 'DB_HOST': 'database',
'DB_NAME': 'mailu', 'DB_NAME': 'mailu',
'SQLITE_DATABASE_FILE': 'data/main.db', 'SQLITE_DATABASE_FILE':'data/main.db',
'SQLALCHEMY_DATABASE_URI': 'sqlite:////data/main.db', 'SQLALCHEMY_DATABASE_URI': 'sqlite:////data/main.db',
'SQLALCHEMY_TRACK_MODIFICATIONS': False, 'SQLALCHEMY_TRACK_MODIFICATIONS': False,
# Statistics management # Statistics management
@ -61,7 +59,7 @@ DEFAULT_CONFIG = {
# Web settings # Web settings
'SITENAME': 'Mailu', 'SITENAME': 'Mailu',
'WEBSITE': 'https://mailu.io', 'WEBSITE': 'https://mailu.io',
'ADMIN': 'none', 'ADMIN' : 'none',
'WEB_ADMIN': '/admin', 'WEB_ADMIN': '/admin',
'WEB_WEBMAIL': '/webmail', 'WEB_WEBMAIL': '/webmail',
'WEBMAIL': 'none', 'WEBMAIL': 'none',
@ -74,14 +72,21 @@ DEFAULT_CONFIG = {
'SESSION_KEY_BITS': 128, 'SESSION_KEY_BITS': 128,
'SESSION_TIMEOUT': 3600, 'SESSION_TIMEOUT': 3600,
'PERMANENT_SESSION_LIFETIME': 30*24*3600, 'PERMANENT_SESSION_LIFETIME': 30*24*3600,
'SESSION_COOKIE_SECURE': None, 'SESSION_COOKIE_SECURE': True,
'CREDENTIAL_ROUNDS': 12, 'CREDENTIAL_ROUNDS': 12,
'TLS_PERMISSIVE': True, 'TLS_PERMISSIVE': True,
'TZ': 'Etc/UTC', 'TZ': 'Etc/UTC',
'DEFAULT_SPAM_THRESHOLD': 80, # Host settings
'PROXY_AUTH_WHITELIST': '', 'HOST_IMAP': 'imap',
'PROXY_AUTH_HEADER': 'X-Auth-Email', 'HOST_LMTP': 'imap:2525',
'PROXY_AUTH_CREATE': False, 'HOST_POP3': 'imap',
'HOST_SMTP': 'smtp',
'HOST_AUTHSMTP': 'smtp',
'HOST_ADMIN': 'admin',
'HOST_WEBMAIL': 'webmail',
'HOST_WEBDAV': 'webdav:5232',
'HOST_REDIS': 'redis',
'HOST_FRONT': 'front',
'SUBNET': '192.168.203.0/24', 'SUBNET': '192.168.203.0/24',
'SUBNET6': None 'SUBNET6': None
} }
@ -99,9 +104,18 @@ class ConfigManager:
def __init__(self): def __init__(self):
self.config = dict() self.config = dict()
def get_host_address(self, name):
# if MYSERVICE_ADDRESS is defined, use this
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[f'HOST_{name}'])
def resolve_hosts(self): def resolve_hosts(self):
for key in ['ADMIN', 'FRONT', 'SMTP', 'IMAP', 'REDIS', 'ANTIVIRUS:', 'ANTISPAM', 'WEBMAIL', 'WEBDAV']: for key in ['IMAP', 'POP3', 'AUTHSMTP', 'SMTP', 'REDIS']:
self.config[f'{key}_ADDRESS'] = os.environ.get(f'{key}_ADDRESS') 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): def __get_env(self, key, value):
key_file = key + "_FILE" key_file = key + "_FILE"
@ -127,7 +141,6 @@ class ConfigManager:
key: self.__coerce_value(self.__get_env(key, value)) key: self.__coerce_value(self.__get_env(key, value))
for key, value in DEFAULT_CONFIG.items() for key, value in DEFAULT_CONFIG.items()
}) })
self.resolve_hosts() self.resolve_hosts()
# automatically set the sqlalchemy string # automatically set the sqlalchemy string
@ -135,26 +148,21 @@ class ConfigManager:
template = self.DB_TEMPLATES[self.config['DB_FLAVOR']] template = self.DB_TEMPLATES[self.config['DB_FLAVOR']]
self.config['SQLALCHEMY_DATABASE_URI'] = template.format(**self.config) self.config['SQLALCHEMY_DATABASE_URI'] = template.format(**self.config)
if not self.config.get('RATELIMIT_STORAGE_URL'):
self.config['RATELIMIT_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/2' 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_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/3'
self.config['SESSION_COOKIE_SAMESITE'] = 'Strict' self.config['SESSION_COOKIE_SAMESITE'] = 'Strict'
self.config['SESSION_COOKIE_HTTPONLY'] = True self.config['SESSION_COOKIE_HTTPONLY'] = True
if self.config['SESSION_COOKIE_SECURE'] is None:
self.config['SESSION_COOKIE_SECURE'] = self.config['TLS_FLAVOR'] != 'notls'
self.config['SESSION_PERMANENT'] = True self.config['SESSION_PERMANENT'] = True
self.config['SESSION_TIMEOUT'] = int(self.config['SESSION_TIMEOUT']) self.config['SESSION_TIMEOUT'] = int(self.config['SESSION_TIMEOUT'])
self.config['PERMANENT_SESSION_LIFETIME'] = int(self.config['PERMANENT_SESSION_LIFETIME']) self.config['PERMANENT_SESSION_LIFETIME'] = int(self.config['PERMANENT_SESSION_LIFETIME'])
self.config['AUTH_RATELIMIT_IP_V4_MASK'] = int(self.config['AUTH_RATELIMIT_IP_V4_MASK']) self.config['AUTH_RATELIMIT_IP_V4_MASK'] = int(self.config['AUTH_RATELIMIT_IP_V4_MASK'])
self.config['AUTH_RATELIMIT_IP_V6_MASK'] = int(self.config['AUTH_RATELIMIT_IP_V6_MASK']) self.config['AUTH_RATELIMIT_IP_V6_MASK'] = int(self.config['AUTH_RATELIMIT_IP_V6_MASK'])
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['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['MESSAGE_RATELIMIT_EXEMPTION'] = set([s for s in self.config['MESSAGE_RATELIMIT_EXEMPTION'].lower().replace(' ', '').split(',') if s])
hostnames = [host.strip() for host in self.config['HOSTNAMES'].split(',')]
self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAMES'] = ','.join(hostnames)
self.config['HOSTNAME'] = hostnames[0] self.config['HOSTNAME'] = hostnames[0]
self.config['DEFAULT_SPAM_THRESHOLD'] = int(self.config['DEFAULT_SPAM_THRESHOLD'])
self.config['PROXY_AUTH_WHITELIST'] = set(ipaddress.ip_network(cidr, False) for cidr in (cidr.strip() for cidr in self.config['PROXY_AUTH_WHITELIST'].split(',')) if cidr)
# update the app config # update the app config
app.config.update(self.config) app.config.update(self.config)

@ -1,10 +1,12 @@
from mailu import models, utils from mailu import models, utils
from flask import current_app as app from flask import current_app as app
from socrate import system
import re
import urllib import urllib
import ipaddress import ipaddress
import socket
import sqlalchemy.exc import sqlalchemy.exc
import tenacity
SUPPORTED_AUTH_METHODS = ["none", "plain"] SUPPORTED_AUTH_METHODS = ["none", "plain"]
@ -125,20 +127,32 @@ def get_status(protocol, status):
status, codes = STATUSES[status] status, codes = STATUSES[status]
return status, codes[protocol] return status, codes[protocol]
def extract_host_port(host_and_port, default_port):
host, _, port = re.match('^(.*?)(:([0-9]*))?$', host_and_port).groups()
return host, int(port) if port else default_port
def get_server(protocol, authenticated=False): def get_server(protocol, authenticated=False):
if protocol == "imap": if protocol == "imap":
hostname, port = app.config['IMAP_ADDRESS'], 143 hostname, port = extract_host_port(app.config['IMAP_ADDRESS'], 143)
elif protocol == "pop3": elif protocol == "pop3":
hostname, port = app.config['IMAP_ADDRESS'], 110 hostname, port = extract_host_port(app.config['POP3_ADDRESS'], 110)
elif protocol == "smtp": elif protocol == "smtp":
if authenticated: if authenticated:
hostname, port = app.config['SMTP_ADDRESS'], 10025 hostname, port = extract_host_port(app.config['AUTHSMTP_ADDRESS'], 10025)
else: else:
hostname, port = app.config['SMTP_ADDRESS'], 25 hostname, port = extract_host_port(app.config['SMTP_ADDRESS'], 25)
try: try:
# test if hostname is already resolved to an ip address # test if hostname is already resolved to an ip adddress
ipaddress.ip_address(hostname) ipaddress.ip_address(hostname)
except: except:
# hostname is not an ip address - so we need to resolve it # hostname is not an ip address - so we need to resolve it
hostname = system.resolve_hostname(hostname) hostname = resolve_hostname(hostname)
return hostname, port return hostname, port
@tenacity.retry(stop=tenacity.stop_after_attempt(100),
wait=tenacity.wait_random(min=2, max=5))
def resolve_hostname(hostname):
""" This function uses system DNS to resolve a hostname.
It is capable of retrying in case the host is not immediately available
"""
return socket.gethostbyname(hostname)

@ -5,7 +5,6 @@ from flask import current_app as app
import flask import flask
import socket import socket
import os import os
import sqlalchemy.exc
@internal.route("/dovecot/passdb/<path:user_email>") @internal.route("/dovecot/passdb/<path:user_email>")
def dovecot_passdb_dict(user_email): def dovecot_passdb_dict(user_email):
@ -20,20 +19,12 @@ def dovecot_passdb_dict(user_email):
"allow_nets": ",".join(allow_nets) "allow_nets": ",".join(allow_nets)
}) })
@internal.route("/dovecot/userdb/")
def dovecot_userdb_dict_list():
return flask.jsonify([
user[0] for user in models.User.query.filter(models.User.enabled.is_(True)).with_entities(models.User.email).all()
])
@internal.route("/dovecot/userdb/<path:user_email>") @internal.route("/dovecot/userdb/<path:user_email>")
def dovecot_userdb_dict(user_email): def dovecot_userdb_dict(user_email):
try: user = models.User.query.get(user_email) or flask.abort(404)
quota = models.User.query.filter(models.User.email==user_email).with_entities(models.User.quota_bytes).one_or_none() or flask.abort(404)
except sqlalchemy.exc.StatementError as exc:
flask.abort(404)
return flask.jsonify({ return flask.jsonify({
"quota_rule": f"*:bytes={quota[0]}" "quota_rule": "*:bytes={}".format(user.quota_bytes)
}) })
@ -42,7 +33,6 @@ def dovecot_quota(ns, user_email):
user = models.User.query.get(user_email) or flask.abort(404) user = models.User.query.get(user_email) or flask.abort(404)
if ns == "storage": if ns == "storage":
user.quota_bytes_used = flask.request.get_json() user.quota_bytes_used = flask.request.get_json()
user.dont_change_updated_at()
models.db.session.commit() models.db.session.commit()
return flask.jsonify(None) return flask.jsonify(None)

@ -12,12 +12,10 @@ def fetch_list():
"id": fetch.id, "id": fetch.id,
"tls": fetch.tls, "tls": fetch.tls,
"keep": fetch.keep, "keep": fetch.keep,
"scan": fetch.scan,
"user_email": fetch.user_email, "user_email": fetch.user_email,
"protocol": fetch.protocol, "protocol": fetch.protocol,
"host": fetch.host, "host": fetch.host,
"port": fetch.port, "port": fetch.port,
"folders": fetch.folders,
"username": fetch.username, "username": fetch.username,
"password": fetch.password "password": fetch.password
} for fetch in models.Fetch.query.all() } for fetch in models.Fetch.query.all()
@ -29,7 +27,6 @@ def fetch_done(fetch_id):
fetch = models.Fetch.query.get(fetch_id) or flask.abort(404) fetch = models.Fetch.query.get(fetch_id) or flask.abort(404)
fetch.last_check = datetime.datetime.now() fetch.last_check = datetime.datetime.now()
fetch.error_message = str(flask.request.get_json()) fetch.error_message = str(flask.request.get_json())
fetch.dont_change_updated_at()
models.db.session.add(fetch) models.db.session.add(fetch)
models.db.session.commit() models.db.session.commit()
return "" return ""

@ -143,9 +143,8 @@ def postfix_sender_login(sender):
if localpart is None: if localpart is None:
return flask.jsonify(",".join(wildcard_senders)) if wildcard_senders else 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)] localpart = localpart[:next((i for i, ch in enumerate(localpart) if ch in flask.current_app.config.get('RECIPIENT_DELIMITER')), None)]
destinations = set(models.Email.resolve_destination(localpart, domain_name, True) or []) destinations = models.Email.resolve_destination(localpart, domain_name, True) or []
destinations.update(wildcard_senders) destinations.extend(wildcard_senders)
destinations.update(i[0] for i in models.User.query.filter_by(allow_spoofing=True).with_entities(models.User.email).all())
if destinations: if destinations:
return flask.jsonify(",".join(idna_encode(destinations))) return flask.jsonify(",".join(idna_encode(destinations)))
return flask.abort(404) return flask.abort(404)
@ -159,6 +158,21 @@ def postfix_sender_rate(sender):
user = models.User.get(sender) or 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.") 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/<path:sender>")
def postfix_sender_access(sender):
""" Simply reject any sender that pretends to be from a local domain
"""
if '@' in sender:
if sender.startswith('<') and sender.endswith('>'):
sender = sender[1:-1]
try:
localpart, domain_name = models.Email.resolve_domain(sender)
if models.Domain.query.get(domain_name):
return flask.jsonify("REJECT")
except sqlalchemy.exc.StatementError:
pass
return flask.abort(404)
# idna encode domain part of each address in list of addresses # idna encode domain part of each address in list of addresses
def idna_encode(addresses): def idna_encode(addresses):
return [ return [

@ -25,7 +25,3 @@ def rspamd_dkim_key(domain_name):
} }
) )
return flask.jsonify({'data': {'selectors': selectors}}) return flask.jsonify({'data': {'selectors': selectors}})
@internal.route("/rspamd/local_domains", methods=['GET'])
def rspamd_local_domains():
return '\n'.join(domain[0] for domain in models.Domain.query.with_entities(models.Domain.name).all() + models.Alternative.query.with_entities(models.Alternative.name).all())

@ -2,6 +2,7 @@
""" """
import os import os
import smtplib
import json import json
from datetime import date from datetime import date
@ -24,7 +25,6 @@ from flask import current_app as app
from sqlalchemy.ext import declarative from sqlalchemy.ext import declarative
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
from sqlalchemy.orm.attributes import flag_modified
from werkzeug.utils import cached_property from werkzeug.utils import cached_property
from mailu import dkim, utils from mailu import dkim, utils
@ -154,10 +154,6 @@ class Base(db.Model):
self.__hashed = id(self) if primary is None else hash(primary) self.__hashed = id(self) if primary is None else hash(primary)
return self.__hashed return self.__hashed
def dont_change_updated_at(self):
""" Mark updated_at as modified, but keep the old date when updating the model"""
flag_modified(self, 'updated_at')
# Many-to-many association table for domain managers # Many-to-many association table for domain managers
managers = db.Table('manager', Base.metadata, managers = db.Table('manager', Base.metadata,
@ -419,18 +415,14 @@ class Email(object):
def sendmail(self, subject, body): def sendmail(self, subject, body):
""" send an email to the address """ """ send an email to the address """
try:
f_addr = f'{app.config["POSTMASTER"]}@{idna.encode(app.config["DOMAIN"]).decode("ascii")}' f_addr = f'{app.config["POSTMASTER"]}@{idna.encode(app.config["DOMAIN"]).decode("ascii")}'
with smtplib.LMTP(ip=app.config['IMAP_ADDRESS'], port=2525) as lmtp: with smtplib.SMTP(app.config['HOST_AUTHSMTP'], port=10025) as smtp:
to_address = f'{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 = text.MIMEText(body)
msg['Subject'] = subject msg['Subject'] = subject
msg['From'] = f_addr msg['From'] = f_addr
msg['To'] = to_address msg['To'] = to_address
lmtp.sendmail(f_addr, [to_address], msg.as_string()) smtp.sendmail(f_addr, [to_address], msg.as_string())
return True
except smtplib.SMTPException:
return False
@classmethod @classmethod
def resolve_domain(cls, email): def resolve_domain(cls, email):
@ -504,7 +496,6 @@ class User(Base, Email):
# Features # Features
enable_imap = 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) enable_pop = db.Column(db.Boolean, nullable=False, default=True)
allow_spoofing = db.Column(db.Boolean, nullable=False, default=False)
# Filters # Filters
forward_enabled = db.Column(db.Boolean, nullable=False, default=False) forward_enabled = db.Column(db.Boolean, nullable=False, default=False)
@ -522,7 +513,7 @@ class User(Base, Email):
displayed_name = db.Column(db.String(160), nullable=False, default='') displayed_name = db.Column(db.String(160), nullable=False, default='')
spam_enabled = db.Column(db.Boolean, nullable=False, default=True) spam_enabled = db.Column(db.Boolean, nullable=False, default=True)
spam_mark_as_read = db.Column(db.Boolean, nullable=False, default=True) spam_mark_as_read = db.Column(db.Boolean, nullable=False, default=True)
spam_threshold = db.Column(db.Integer, nullable=False, default=lambda:int(app.config.get("DEFAULT_SPAM_THRESHOLD", 80))) spam_threshold = db.Column(db.Integer, nullable=False, default=80)
# Flask-login attributes # Flask-login attributes
is_authenticated = True is_authenticated = True
@ -550,8 +541,8 @@ class User(Base, Email):
now = date.today() now = date.today()
return ( return (
self.reply_enabled and self.reply_enabled and
self.reply_startdate <= now and self.reply_startdate < now and
self.reply_enddate >= now self.reply_enddate > now
) )
@property @property
@ -775,8 +766,6 @@ class Fetch(Base):
username = db.Column(db.String(255), nullable=False) username = db.Column(db.String(255), nullable=False)
password = db.Column(db.String(255), nullable=False) password = db.Column(db.String(255), nullable=False)
keep = db.Column(db.Boolean, nullable=False, default=False) keep = db.Column(db.Boolean, nullable=False, default=False)
scan = db.Column(db.Boolean, nullable=False, default=False)
folders = db.Column(CommaSeparatedList, nullable=True, default=list)
last_check = db.Column(db.DateTime, nullable=True) last_check = db.Column(db.DateTime, nullable=True)
error = db.Column(db.String(1023), nullable=True) error = db.Column(db.String(1023), nullable=True)

@ -968,7 +968,7 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
) from exc ) from exc
# sort list of new values # sort list of new values
data[key] = sorted(new_value) data[key] = sorted(new_value)
# log backref modification not caught by modify hook # log backref modification not catched by modify hook
if isinstance(self.fields[key], RelatedList): if isinstance(self.fields[key], RelatedList):
if callback := self.context.get('callback'): if callback := self.context.get('callback'):
before = {str(v) for v in getattr(instance, key)} before = {str(v) for v in getattr(instance, key)}

@ -7,6 +7,5 @@ class LoginForm(flask_wtf.FlaskForm):
csrf = False csrf = False
email = fields.StringField(_('E-mail'), [validators.Email(), validators.DataRequired()]) email = fields.StringField(_('E-mail'), [validators.Email(), validators.DataRequired()])
pw = fields.PasswordField(_('Password'), [validators.DataRequired()]) pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
pwned = fields.HiddenField(label='', default=-1)
submitWebmail = fields.SubmitField(_('Sign in')) submitWebmail = fields.SubmitField(_('Sign in'))
submitAdmin = fields.SubmitField(_('Sign in')) submitAdmin = fields.SubmitField(_('Sign in'))

@ -1,7 +1,7 @@
{%- import "macros.html" as macros %} {%- import "macros.html" as macros %}
{%- import "bootstrap/utils.html" as utils %} {%- import "bootstrap/utils.html" as utils %}
<!doctype html> <!doctype html>
<html lang="{{ get_locale() }}" data-static="/static/"> <html lang="{{ session['language'] }}" data-static="/static/">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -34,8 +34,8 @@
<ul class="navbar-nav ml-auto"> <ul class="navbar-nav ml-auto">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link" data-toggle="dropdown" href="#" aria-expanded="false"> <a class="nav-link" data-toggle="dropdown" href="#" aria-expanded="false">
<i class="fas fa-language text-xl" aria-hidden="true" title="{% trans %}change language{% endtrans %}"></i><span class="sr-only">{% trans %}change language{% endtrans %}</span> <i class="fas fa-language text-xl" aria-hidden="true" title="{% trans %}change language{% endtrans %}"></i><span class="sr-only">Language</span>
<span class="badge badge-primary navbar-badge">{{ get_locale() }}</span></a> <span class="badge badge-primary navbar-badge">{{ session['language'] }}</span></a>
<div class="dropdown-menu dropdown-menu-right p-0" id="mailu-languages"> <div class="dropdown-menu dropdown-menu-right p-0" id="mailu-languages">
{%- for locale in config.translations.values() %} {%- for locale in config.translations.values() %}
<a class="dropdown-item{% if locale|string() == session['language'] %} active{% endif %}" href="{{ url_for('sso.set_language', language=locale) }}">{{ locale.get_language_name().title() }}</a> <a class="dropdown-item{% if locale|string() == session['language'] %} active{% endif %}" href="{{ url_for('sso.set_language', language=locale) }}">{{ locale.get_language_name().title() }}</a>

@ -3,7 +3,6 @@
{%- block content %} {%- block content %}
{%- call macros.card() %} {%- call macros.card() %}
<form class="form" method="post" role="form"> <form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.email) }} {{ macros.form_field(form.email) }}
{{ macros.form_field(form.pw) }} {{ macros.form_field(form.pw) }}
{{ macros.form_fields(fields, label=False, class="btn btn-default") }} {{ macros.form_fields(fields, label=False, class="btn btn-default") }}

@ -36,12 +36,6 @@
</a> </a>
</li> </li>
{%- endif %} {%- endif %}
<li class="nav-item" role="none">
<a href="{{ url_for('sso.login') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-sign-in-alt"></i>
<p>{% trans %}Sign in{% endtrans %}</p>
</a>
</li>
{#- {#-
User self-registration is only available when User self-registration is only available when
- Admin is available - Admin is available

@ -6,8 +6,6 @@ from mailu.ui import access
from flask import current_app as app from flask import current_app as app
import flask import flask
import flask_login import flask_login
import secrets
import ipaddress
@sso.route('/login', methods=['GET', 'POST']) @sso.route('/login', methods=['GET', 'POST'])
def login(): def login():
@ -42,9 +40,7 @@ def login():
flask_login.login_user(user) flask_login.login_user(user)
response = flask.redirect(destination) 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) 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} pwned={form.pwned.data}.') flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.')
if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error")
return response return response
else: 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) 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)
@ -59,41 +55,3 @@ def logout():
flask.session.destroy() flask.session.destroy()
return flask.redirect(flask.url_for('.login')) return flask.redirect(flask.url_for('.login'))
@sso.route('/proxy', methods=['GET'])
@sso.route('/proxy/<target>', methods=['GET'])
def proxy(target='webmail'):
ip = ipaddress.ip_address(flask.request.remote_addr)
if not any(ip in cidr for cidr in app.config['PROXY_AUTH_WHITELIST']):
return flask.abort(500, '%s is not on PROXY_AUTH_WHITELIST' % flask.request.remote_addr)
email = flask.request.headers.get(app.config['PROXY_AUTH_HEADER'])
if not email:
return flask.abort(500, 'No %s header' % app.config['PROXY_AUTH_HEADER'])
user = models.User.get(email)
if user:
flask.session.regenerate()
flask_login.login_user(user)
return flask.redirect(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL'])
if not app.config['PROXY_AUTH_CREATE']:
return flask.abort(500, 'You don\'t exist. Go away! (%s)' % email)
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
try:
localpart, desireddomain = email.rsplit('@')
except Exception as e:
flask.current_app.logger.error('Error creating a new user via proxy for %s from %s: %s' % (email, client_ip, str(e)), e)
return flask.abort(500, 'You don\'t exist. Go away! (%s)' % email)
domain = models.Domain.query.get(desireddomain) or flask.abort(500, 'You don\'t exist. Go away! (domain=%s)' % desireddomain)
if not domain.max_users == -1 and len(domain.users) >= domain.max_users:
flask.current_app.logger.warning('Too many users for domain %s' % domain)
return flask.abort(500, 'Too many users in (domain=%s)' % domain)
user = models.User(localpart=localpart, domain=domain)
user.set_password(secrets.token_urlsafe())
models.db.session.add(user)
models.db.session.commit()
user.send_welcome()
flask.current_app.logger.info(f'Login succeeded by proxy created user: {user} from {client_ip} through {flask.request.remote_addr}.')
return flask.redirect(app.config['WEB_ADMIN'] if target=='admin' else app.config['WEB_WEBMAIL'])

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

@ -660,7 +660,7 @@ msgid "New relay domain"
msgstr "Nou domini llegat (relayed)" msgstr "Nou domini llegat (relayed)"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Editeu domini llegat (relayed)" msgstr "Editeu domini llegat (relayed)"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -649,7 +649,7 @@ msgid "New relay domain"
msgstr "" msgstr ""
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "" msgstr ""
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -655,7 +655,7 @@ msgid "New relay domain"
msgstr "Neue Relay-Domain" msgstr "Neue Relay-Domain"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Relay-Domain bearbeiten" msgstr "Relay-Domain bearbeiten"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -649,7 +649,7 @@ msgid "New relay domain"
msgstr "New relay domain" msgstr "New relay domain"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "" msgstr ""
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -651,7 +651,7 @@ msgid "New relay domain"
msgstr "Nuevo dominio externo (relay)" msgstr "Nuevo dominio externo (relay)"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Editar dominio externo (relay)" msgstr "Editar dominio externo (relay)"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -649,7 +649,7 @@ msgid "New relay domain"
msgstr "Igorritako domeinu berria" msgstr "Igorritako domeinu berria"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Editatu igorritako domeinua" msgstr "Editatu igorritako domeinua"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -653,7 +653,7 @@ msgid "New relay domain"
msgstr "Nouveau domaine relayé" msgstr "Nouveau domaine relayé"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Modifier le domaine relayé" msgstr "Modifier le domaine relayé"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -658,7 +658,7 @@ msgid "New relay domain"
msgstr "שם תחום מועבר" msgstr "שם תחום מועבר"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "עריכת שמות תחום מועברים" msgstr "עריכת שמות תחום מועברים"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -652,7 +652,7 @@ msgid "New relay domain"
msgstr "Új továbbító tartomány" msgstr "Új továbbító tartomány"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Továbbított tartomány szerkesztése" msgstr "Továbbított tartomány szerkesztése"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -648,7 +648,7 @@ msgid "New relay domain"
msgstr "" msgstr ""
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "" msgstr ""
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -655,7 +655,7 @@ msgid "New relay domain"
msgstr "Nuovo dominio affidato" msgstr "Nuovo dominio affidato"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Editare dominio affidato" msgstr "Editare dominio affidato"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -649,7 +649,7 @@ msgid "New relay domain"
msgstr "リレードメイン名を追加" msgstr "リレードメイン名を追加"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "リレードメイン名を編集" msgstr "リレードメイン名を編集"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -657,7 +657,7 @@ msgid "New relay domain"
msgstr "Nytt videresendt domene" msgstr "Nytt videresendt domene"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Endre videresendt domene" msgstr "Endre videresendt domene"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -650,7 +650,7 @@ msgid "New relay domain"
msgstr "Nieuw relay domein" msgstr "Nieuw relay domein"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Bewerk relay domein" msgstr "Bewerk relay domein"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -659,7 +659,7 @@ msgstr "Nowa domena do przekierowania"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
#, fuzzy #, fuzzy
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Edycja domeny" msgstr "Edycja domeny"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -650,7 +650,7 @@ msgid "New relay domain"
msgstr "Novo domínio para encaminhamento" msgstr "Novo domínio para encaminhamento"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Editar domínio de encaminhamento" msgstr "Editar domínio de encaminhamento"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -656,7 +656,7 @@ msgid "New relay domain"
msgstr "Новый релейный домен" msgstr "Новый релейный домен"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Изменить релейный домен" msgstr "Изменить релейный домен"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -645,7 +645,7 @@ msgid "New relay domain"
msgstr "Ny relä-domän" msgstr "Ny relä-domän"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "Redigera reläade domäner" msgstr "Redigera reläade domäner"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -646,7 +646,7 @@ msgid "New relay domain"
msgstr "新的中继域" msgstr "新的中继域"
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "编辑中继域" msgstr "编辑中继域"
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -41,16 +41,6 @@ class MultipleEmailAddressesVerify(object):
if not pattern.match(field.data.replace(" ", "")): if not pattern.match(field.data.replace(" ", "")):
raise validators.ValidationError(self.message) raise validators.ValidationError(self.message)
class MultipleFoldersVerify(object):
""" Ensure that we have CSV formated data """
def __init__(self,message=_('Invalid list of folders.')):
self.message = message
def __call__(self, form, field):
pattern = re.compile(r'^\w+(\s*,\s*\w+)*$')
if not pattern.match(field.data.replace(" ", "")):
raise validators.ValidationError(self.message)
class ConfirmationForm(flask_wtf.FlaskForm): class ConfirmationForm(flask_wtf.FlaskForm):
submit = fields.SubmitField(_('Confirm')) submit = fields.SubmitField(_('Confirm'))
@ -69,7 +59,6 @@ class DomainSignupForm(flask_wtf.FlaskForm):
localpart = fields.StringField(_('Initial admin'), [validators.DataRequired()]) localpart = fields.StringField(_('Initial admin'), [validators.DataRequired()])
pw = fields.PasswordField(_('Admin password'), [validators.DataRequired()]) pw = fields.PasswordField(_('Admin password'), [validators.DataRequired()])
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')]) pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
pwned = fields.HiddenField(label='', default=-1)
captcha = flask_wtf.RecaptchaField() captcha = flask_wtf.RecaptchaField()
submit = fields.SubmitField(_('Create')) submit = fields.SubmitField(_('Create'))
@ -90,11 +79,9 @@ class UserForm(flask_wtf.FlaskForm):
localpart = fields.StringField(_('E-mail'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)]) localpart = fields.StringField(_('E-mail'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)])
pw = fields.PasswordField(_('Password')) pw = fields.PasswordField(_('Password'))
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')]) pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
pwned = fields.HiddenField(label='', default=-1)
quota_bytes = fields_.IntegerSliderField(_('Quota'), default=10**9) quota_bytes = fields_.IntegerSliderField(_('Quota'), default=10**9)
enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True) enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True)
enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True) enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True)
allow_spoofing = fields.BooleanField(_('Allow the user to spoof the sender (send email as anyone)'), default=False)
displayed_name = fields.StringField(_('Displayed name')) displayed_name = fields.StringField(_('Displayed name'))
comment = fields.StringField(_('Comment')) comment = fields.StringField(_('Comment'))
enabled = fields.BooleanField(_('Enabled'), default=True) enabled = fields.BooleanField(_('Enabled'), default=True)
@ -105,7 +92,6 @@ class UserSignupForm(flask_wtf.FlaskForm):
localpart = fields.StringField(_('Email address'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)]) localpart = fields.StringField(_('Email address'), [validators.DataRequired(), validators.Regexp(LOCALPART_REGEX)])
pw = fields.PasswordField(_('Password'), [validators.DataRequired()]) pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')]) pw2 = fields.PasswordField(_('Confirm password'), [validators.EqualTo('pw')])
pwned = fields.HiddenField(label='', default=-1)
submit = fields.SubmitField(_('Sign up')) submit = fields.SubmitField(_('Sign up'))
class UserSignupFormCaptcha(UserSignupForm): class UserSignupFormCaptcha(UserSignupForm):
@ -125,7 +111,6 @@ class UserSettingsForm(flask_wtf.FlaskForm):
class UserPasswordForm(flask_wtf.FlaskForm): class UserPasswordForm(flask_wtf.FlaskForm):
pw = fields.PasswordField(_('Password'), [validators.DataRequired()]) pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
pw2 = fields.PasswordField(_('Password check'), [validators.DataRequired()]) pw2 = fields.PasswordField(_('Password check'), [validators.DataRequired()])
pwned = fields.HiddenField(label='', default=-1)
submit = fields.SubmitField(_('Update password')) submit = fields.SubmitField(_('Update password'))
@ -134,8 +119,8 @@ class UserReplyForm(flask_wtf.FlaskForm):
reply_subject = fields.StringField(_('Reply subject')) reply_subject = fields.StringField(_('Reply subject'))
reply_body = fields.StringField(_('Reply body'), reply_body = fields.StringField(_('Reply body'),
widget=widgets.TextArea()) widget=widgets.TextArea())
reply_startdate = fields.DateField(_('Start of vacation')) reply_startdate = fields.html5.DateField(_('Start of vacation'))
reply_enddate = fields.DateField(_('End of vacation')) reply_enddate = fields.html5.DateField(_('End of vacation'))
submit = fields.SubmitField(_('Update')) submit = fields.SubmitField(_('Update'))
@ -175,13 +160,11 @@ class FetchForm(flask_wtf.FlaskForm):
('imap', 'IMAP'), ('pop3', 'POP3') ('imap', 'IMAP'), ('pop3', 'POP3')
]) ])
host = fields.StringField(_('Hostname or IP'), [validators.DataRequired()]) host = fields.StringField(_('Hostname or IP'), [validators.DataRequired()])
port = fields.IntegerField(_('TCP port'), [validators.DataRequired(), validators.NumberRange(min=0, max=65535)], default=993) port = fields.IntegerField(_('TCP port'), [validators.DataRequired(), validators.NumberRange(min=0, max=65535)])
tls = fields.BooleanField(_('Enable TLS'), default=True) tls = fields.BooleanField(_('Enable TLS'))
username = fields.StringField(_('Username'), [validators.DataRequired()]) username = fields.StringField(_('Username'), [validators.DataRequired()])
password = fields.PasswordField(_('Password')) password = fields.PasswordField(_('Password'))
keep = fields.BooleanField(_('Keep emails on the server')) keep = fields.BooleanField(_('Keep emails on the server'))
scan = fields.BooleanField(_('Rescan emails locally'))
folders = fields.StringField(_('Folders to fetch on the server'), [validators.Optional(), MultipleFoldersVerify()], default='INBOX,Junk')
submit = fields.SubmitField(_('Submit')) submit = fields.SubmitField(_('Submit'))

@ -14,7 +14,7 @@
{%- call macros.table() %} {%- call macros.table() %}
<thead> <thead>
<tr> <tr>
<th data-orderable="false">{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
<th>{% trans %}Email{% endtrans %}</th> <th>{% trans %}Email{% endtrans %}</th>
</tr> </tr>
</thead> </thead>

@ -16,7 +16,7 @@
{%- call macros.table() %} {%- call macros.table() %}
<thead> <thead>
<tr> <tr>
<th data-orderable="false">{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
<th>{% trans %}Email{% endtrans %}</th> <th>{% trans %}Email{% endtrans %}</th>
<th>{% trans %}Destination{% endtrans %}</th> <th>{% trans %}Destination{% endtrans %}</th>
<th>{% trans %}Comment{% endtrans %}</th> <th>{% trans %}Comment{% endtrans %}</th>
@ -34,8 +34,8 @@
<td>{{ alias }}</td> <td>{{ alias }}</td>
<td>{{ alias.destination|join(', ') or '-' }}</td> <td>{{ alias.destination|join(', ') or '-' }}</td>
<td>{{ alias.comment or '' }}</td> <td>{{ alias.comment or '' }}</td>
<td data-sort="{{ alias.created_at or '0000-00-00' }}">{{ alias.created_at | format_date }}</td> <td>{{ alias.created_at | format_date }}</td>
<td data-sort="{{ alias.updated_at or '0000-00-00' }}">{{ alias.updated_at | format_date }}</td> <td>{{ alias.updated_at | format_date }}</td>
</tr> </tr>
{%- endfor %} {%- endfor %}
</tbody> </tbody>

@ -16,7 +16,7 @@
{%- call macros.table() %} {%- call macros.table() %}
<thead> <thead>
<tr> <tr>
<th data-orderable="false">{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
<th>{% trans %}Name{% endtrans %}</th> <th>{% trans %}Name{% endtrans %}</th>
<th>{% trans %}Created{% endtrans %}</th> <th>{% trans %}Created{% endtrans %}</th>
<th>{% trans %}Last edit{% endtrans %}</th> <th>{% trans %}Last edit{% endtrans %}</th>
@ -29,8 +29,8 @@
<a href="{{ url_for('.alternative_delete', alternative=alternative.name) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a> <a href="{{ url_for('.alternative_delete', alternative=alternative.name) }}" title="{% trans %}Delete{% endtrans %}"><i class="fa fa-trash"></i></a>
</td> </td>
<td>{{ alternative }}</td> <td>{{ alternative }}</td>
<td data-sort="{{ alternative.created_at or '0000-00-00' }}">{{ alternative.created_at | format_date }}</td> <td>{{ alternative.created_at | format_date }}</td>
<td data-sort="{{ alternative.updated_at or '0000-00-00' }}">{{ alternative.updated_at | format_date }}</td> <td>{{ alternative.updated_at | format_date }}</td>
</tr> </tr>
{%- endfor %} {%- endfor %}
</tbody> </tbody>

@ -1,7 +1,7 @@
{%- import "macros.html" as macros %} {%- import "macros.html" as macros %}
{%- import "bootstrap/utils.html" as utils %} {%- import "bootstrap/utils.html" as utils %}
<!doctype html> <!doctype html>
<html lang="{{ get_locale() }}" data-static="/static/"> <html lang="{{ session['language'] }}" data-static="/static/">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -34,8 +34,8 @@
<ul class="navbar-nav ml-auto"> <ul class="navbar-nav ml-auto">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link" data-toggle="dropdown" href="#" aria-expanded="false"> <a class="nav-link" data-toggle="dropdown" href="#" aria-expanded="false">
<i class="fas fa-language text-xl" aria-hidden="true" title="{% trans %}change language{% endtrans %}"></i><span class="sr-only">{% trans %}change language{% endtrans %}</span> <i class="fas fa-language text-xl" aria-hidden="true" title="{% trans %}change language{% endtrans %}"></i><span class="sr-only">Language</span>
<span class="badge badge-primary navbar-badge">{{ get_locale() }}</span></a> <span class="badge badge-primary navbar-badge">{{ session['language'] }}</span></a>
<div class="dropdown-menu dropdown-menu-right p-0" id="mailu-languages"> <div class="dropdown-menu dropdown-menu-right p-0" id="mailu-languages">
{%- for locale in config.translations.values() %} {%- for locale in config.translations.values() %}
<a class="dropdown-item{% if locale|string() == session['language'] %} active{% endif %}" href="{{ url_for('.set_language', language=locale) }}">{{ locale.get_language_name().title() }}</a> <a class="dropdown-item{% if locale|string() == session['language'] %} active{% endif %}" href="{{ url_for('.set_language', language=locale) }}">{{ locale.get_language_name().title() }}</a>

@ -9,6 +9,7 @@
{%- endblock %} {%- endblock %}
{%- block content %} {%- block content %}
<div>If you use an Apple device, <a href="/apple.mobileconfig">click here to autoconfigure it.</a></div>
{%- call macros.table(title=_("Incoming mail"), datatable=False) %} {%- call macros.table(title=_("Incoming mail"), datatable=False) %}
<tbody> <tbody>
<tr> <tr>
@ -21,7 +22,7 @@
</tr> </tr>
<tr> <tr>
<th>{% trans %}Server name{% endtrans %}</th> <th>{% trans %}Server name{% endtrans %}</th>
<td><pre class="pre-config border bg-light">{{ config["HOSTNAME"] }}</pre></td> <td><pre class="pre-config border bg-light">{{ config["HOSTNAMES"] }}</pre></td>
</tr> </tr>
<tr> <tr>
<th>{% trans %}Username{% endtrans %}</th> <th>{% trans %}Username{% endtrans %}</th>
@ -46,7 +47,7 @@
</tr> </tr>
<tr> <tr>
<th>{% trans %}Server name{% endtrans %}</th> <th>{% trans %}Server name{% endtrans %}</th>
<td><pre class="pre-config border bg-light">{{ config["HOSTNAME"] }}</pre></td> <td><pre class="pre-config border bg-light">{{ config["HOSTNAMES"] }}</pre></td>
</tr> </tr>
<tr> <tr>
<th>{% trans %}Username{% endtrans %}</th> <th>{% trans %}Username{% endtrans %}</th>
@ -58,8 +59,4 @@
</tr> </tr>
</tbody> </tbody>
{%- endcall %} {%- endcall %}
<blockquote>
{% trans %}If you use an Apple device,{% endtrans %}
<a href="/apple.mobileconfig">{% trans %}click here to autoconfigure it.{% endtrans %}</a>
</blockquote>
{%- endblock %} {%- endblock %}

@ -10,7 +10,7 @@
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ macros.form_field(form.name) }} {{ macros.form_field(form.name) }}
{{ macros.form_fields((form.max_users, form.max_aliases)) }} {{ macros.form_fields((form.max_users, form.max_aliases)) }}
{{ macros.form_field(form.max_quota_bytes, step=50*10**6, max=50*10**9, data_infinity="true", {{ macros.form_field(form.max_quota_bytes, step=10**9, max=50*10**9, data_infinity="true",
prepend='<span class="input-group-text"><span id="max_quota_bytes_value"></span>&nbsp;GB</span>') }} prepend='<span class="input-group-text"><span id="max_quota_bytes_value"></span>&nbsp;GB</span>') }}
{{ macros.form_field(form.signup_enabled) }} {{ macros.form_field(form.signup_enabled) }}
{{ macros.form_field(form.comment) }} {{ macros.form_field(form.comment) }}

@ -11,16 +11,15 @@
{%- endblock %} {%- endblock %}
{%- block content %} {%- block content %}
{%- call macros.table(order='[[2,"asc"]]') %} {%- call macros.table() %}
<thead> <thead>
<tr> <tr>
<th data-orderable="false">{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
<th data-orderable="false">{% trans %}Manage{% endtrans %}</th> <th>{% trans %}Manage{% endtrans %}</th>
<th>{% trans %}Domain name{% endtrans %}</th> <th>{% trans %}Domain name{% endtrans %}</th>
<th>{% trans %}Mailbox count{% endtrans %}</th> <th>{% trans %}Mailbox count{% endtrans %}</th>
<th>{% trans %}Alias count{% endtrans %}</th> <th>{% trans %}Alias count{% endtrans %}</th>
<th>{% trans %}Comment{% endtrans %}</th> <th>{% trans %}Comment{% endtrans %}</th>
<th>{% trans %}Enable sign-up{% endtrans %}</th>
<th>{% trans %}Created{% endtrans %}</th> <th>{% trans %}Created{% endtrans %}</th>
<th>{% trans %}Last edit{% endtrans %}</th> <th>{% trans %}Last edit{% endtrans %}</th>
</tr> </tr>
@ -44,12 +43,11 @@
{%- endif %} {%- endif %}
</td> </td>
<td>{{ domain.name }}</td> <td>{{ domain.name }}</td>
<td data-order="{{ domain.users | count }}">{{ domain.users | count }} / {{ '∞' if domain.max_users == -1 else domain.max_users }}</td> <td>{{ domain.users | count }} / {{ '∞' if domain.max_users == -1 else domain.max_users }}</td>
<td data-order="{{ domain.aliases | count }}">{{ domain.aliases | count }} / {{ '∞' if domain.max_aliases == -1 else domain.max_aliases }}</td> <td>{{ domain.aliases | count }} / {{ '∞' if domain.max_aliases == -1 else domain.max_aliases }}</td>
<td>{{ domain.comment or '' }}</td> <td>{{ domain.comment or '' }}</td>
<td data-sort="{{ domain.signup_enabled }}">{% if domain.signup_enabled %}{% trans %}yes{% endtrans %}{% else %}{% trans %}no{% endtrans %}{% endif %}</td> <td>{{ domain.created_at | format_date }}</td>
<td data-order="{{ domain.created_at or '0000-00-00' }}">{{ domain.created_at | format_date }}</td> <td>{{ domain.updated_at | format_date }}</td>
<td data-order="{{ domain.updated_at or '0000-00-00' }}">{{ domain.updated_at | format_date }}</td>
</tr> </tr>
{%- endfor %} {%- endfor %}
</tbody> </tbody>

@ -24,8 +24,6 @@
{%- call macros.card(title="Settings") %} {%- call macros.card(title="Settings") %}
{{ macros.form_field(form.keep) }} {{ macros.form_field(form.keep) }}
{{ macros.form_field(form.scan) }}
{{ macros.form_field(form.folders) }}
{%- endcall %} {%- endcall %}
{{ macros.form_field(form.submit) }} {{ macros.form_field(form.submit) }}

@ -16,12 +16,10 @@
{%- call macros.table() %} {%- call macros.table() %}
<thead> <thead>
<tr> <tr>
<th data-orderable="false">{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
<th>{% trans %}Endpoint{% endtrans %}</th> <th>{% trans %}Endpoint{% endtrans %}</th>
<th>{% trans %}Username{% endtrans %}</th> <th>{% trans %}Username{% endtrans %}</th>
<th>{% trans %}Keep emails{% endtrans %}</th> <th>{% trans %}Keep emails{% endtrans %}</th>
<th>{% trans %}Rescan emails{% endtrans %}</th>
<th>{% trans %}Folders{% endtrans %}</th>
<th>{% trans %}Last check{% endtrans %}</th> <th>{% trans %}Last check{% endtrans %}</th>
<th>{% trans %}Status{% endtrans %}</th> <th>{% trans %}Status{% endtrans %}</th>
<th>{% trans %}Created{% endtrans %}</th> <th>{% trans %}Created{% endtrans %}</th>
@ -37,13 +35,11 @@
</td> </td>
<td>{{ fetch.protocol }}{{ 's' if fetch.tls else '' }}://{{ fetch.host }}:{{ fetch.port }}</td> <td>{{ fetch.protocol }}{{ 's' if fetch.tls else '' }}://{{ fetch.host }}:{{ fetch.port }}</td>
<td>{{ fetch.username }}</td> <td>{{ fetch.username }}</td>
<td data-sort="{{ fetch.keep }}">{% if fetch.keep %}{% trans %}yes{% endtrans %}{% else %}{% trans %}no{% endtrans %}{% endif %}</td> <td>{% if fetch.keep %}{% trans %}yes{% endtrans %}{% else %}{% trans %}no{% endtrans %}{% endif %}</td>
<td data-sort="{{ fetch.scan }}">{% if fetch.scan %}{% trans %}yes{% endtrans %}{% else %}{% trans %}no{% endtrans %}{% endif %}</td>
<td>{{ fetch.folders | join(',') }}</td>
<td>{{ fetch.last_check | format_datetime or '-' }}</td> <td>{{ fetch.last_check | format_datetime or '-' }}</td>
<td>{{ fetch.error or '-' }}</td> <td>{{ fetch.error or '-' }}</td>
<td data-sort="{{ fetch.created_at or '0000-00-00' }}">{{ fetch.created_at | format_date }}</td> <td>{{ fetch.created_at | format_date }}</td>
<td data-sort="{{ fetch.updated_at or '0000-00-00' }}">{{ fetch.updated_at | format_date }}</td> <td>{{ fetch.updated_at | format_date }}</td>
</tr> </tr>
{%- endfor %} {%- endfor %}
</tbody> </tbody>

@ -3,7 +3,7 @@
{%- for fieldname, errors in form.errors.items() %} {%- for fieldname, errors in form.errors.items() %}
{%- if bootstrap_is_hidden_field(form[fieldname]) %} {%- if bootstrap_is_hidden_field(form[fieldname]) %}
{%- for error in errors %} {%- for error in errors %}
<p class="form-text text-danger">{{error}}</p> <p class="error">{{error}}</p>
{%- endfor %} {%- endfor %}
{%- endif %} {%- endif %}
{%- endfor %} {%- endfor %}
@ -13,7 +13,7 @@
{%- macro form_field_errors(field) %} {%- macro form_field_errors(field) %}
{%- if field.errors %} {%- if field.errors %}
{%- for error in field.errors %} {%- for error in field.errors %}
<p class="form-text text-danger">{{ error }}</p> <p class="help-block inline">{{ error }}</p>
{%- endfor %} {%- endfor %}
{%- endif %} {%- endif %}
{%- endmacro %} {%- endmacro %}
@ -23,7 +23,7 @@
<div class="form-group"> <div class="form-group">
<div class="row"> <div class="row">
{%- for field in fields %} {%- for field in fields %}
<div class="col-lg-{{ width }} col-xs-12"> <div class="col-lg-{{ width }} col-xs-12 {{ 'has-error' if field.errors else '' }}">
{%- if field.__class__.__name__ == 'list' %} {%- if field.__class__.__name__ == 'list' %}
{%- for subfield in field %} {%- for subfield in field %}
{{ form_individual_field(subfield, prepend=prepend, append=append, label=label, **kwargs) }} {{ form_individual_field(subfield, prepend=prepend, append=append, label=label, **kwargs) }}
@ -38,13 +38,12 @@
{%- endmacro %} {%- endmacro %}
{%- macro form_individual_field(field, prepend='', append='', label=True, class_="") %} {%- macro form_individual_field(field, prepend='', append='', label=True, class_="") %}
{%- set fieldclass=" ".join(["form-control"] + ([class_] if class_ else []) + (["is-invalid"] if field.errors else [])) %}
{%- if field.type == "BooleanField" %} {%- if field.type == "BooleanField" %}
{{ field(**kwargs) }}<span>&nbsp;&nbsp;</span>{{ field.label if label else '' }} {{ field(**kwargs) }}<span>&nbsp;&nbsp;</span>{{ field.label if label else '' }}
{%- else %} {%- else %}
{{ field.label if label else '' }}{{ form_field_errors(field) }} {{ field.label if label else '' }}{{ form_field_errors(field) }}
{%- if prepend %}<div class="input-group-prepend">{%- elif append %}<div class="input-group-append">{%- endif %} {%- if prepend %}<div class="input-group-prepend">{%- elif append %}<div class="input-group-append">{%- endif %}
{{ prepend|safe }}{{ field(class_=fieldclass, **kwargs) }}{{ append|safe }} {{ prepend|safe }}{{ field(class_=("form-control " + class_) if class_ else "form-control", **kwargs) }}{{ append|safe }}
{%- if prepend or append %}</div>{%- endif %} {%- if prepend or append %}</div>{%- endif %}
{%- endif %} {%- endif %}
{%- endmacro %} {%- endmacro %}
@ -61,7 +60,9 @@
<form class="form" method="post" role="form"> <form class="form" method="post" role="form">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{%- for field in form %} {%- for field in form %}
{%- if not bootstrap_is_hidden_field(field) %} {%- if bootstrap_is_hidden_field(field) %}
{{ field() }}
{%- else %}
{{ form_field(field) }} {{ form_field(field) }}
{%- endif %} {%- endif %}
{%- endfor %} {%- endfor %}
@ -87,7 +88,7 @@
</div> </div>
{%- endmacro %} {%- endmacro %}
{%- macro table(title=None, theme="primary", datatable=True, order=None) %} {%- macro table(title=None, theme="primary", datatable=True) %}
<div class="row"> <div class="row">
<div class="col-lg-12"> <div class="col-lg-12">
<div class="card card-outline card-{{ theme }}"> <div class="card card-outline card-{{ theme }}">
@ -97,7 +98,7 @@
</div> </div>
{%- endif %} {%- endif %}
<div class="card-body"> <div class="card-body">
<table class="table table-bordered{% if datatable %} dataTable{% endif %}" data-order="{{ order or '[]' | e }}"> <table class="table table-bordered{% if datatable %} dataTable{% endif %}">
{{- caller() }} {{- caller() }}
</table> </table>
</div> </div>

@ -13,10 +13,10 @@
{%- endblock %} {%- endblock %}
{%- block content %} {%- block content %}
{%- call macros.table(order='[[2,"asc"]]') %} {%- call macros.table() %}
<thead> <thead>
<tr> <tr>
<th data-orderable="false">{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
<th>{% trans %}Email{% endtrans %}</th> <th>{% trans %}Email{% endtrans %}</th>
</tr> </tr>
</thead> </thead>

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

@ -11,10 +11,10 @@
{%- endblock %} {%- endblock %}
{%- block content %} {%- block content %}
{%- call macros.table(order='[[1,"asc"]]') %} {%- call macros.table() %}
<thead> <thead>
<tr> <tr>
<th data-orderable="false">{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
<th>{% trans %}Domain name{% endtrans %}</th> <th>{% trans %}Domain name{% endtrans %}</th>
<th>{% trans %}Remote host{% endtrans %}</th> <th>{% trans %}Remote host{% endtrans %}</th>
<th>{% trans %}Comment{% endtrans %}</th> <th>{% trans %}Comment{% endtrans %}</th>
@ -32,8 +32,8 @@
<td>{{ relay.name }}</td> <td>{{ relay.name }}</td>
<td>{{ relay.smtp or '-' }}</td> <td>{{ relay.smtp or '-' }}</td>
<td>{{ relay.comment or '' }}</td> <td>{{ relay.comment or '' }}</td>
<td data-sort="{{ relay.created_at or '0000-00-00' }}">{{ relay.created_at | format_date }}</td> <td>{{ relay.created_at | format_date }}</td>
<td data-sort="{{ relay.updated_at or '0000-00-00' }}">{{ relay.updated_at | format_date }}</td> <td>{{ relay.updated_at | format_date }}</td>
</tr> </tr>
{%- endfor %} {%- endfor %}
</tbody> </tbody>

@ -31,14 +31,12 @@
<p>{% trans %}Auto-reply{% endtrans %}</p> <p>{% trans %}Auto-reply{% endtrans %}</p>
</a> </a>
</li> </li>
{%- if config["FETCHMAIL_ENABLED"] %}
<li class="nav-item" role="none"> <li class="nav-item" role="none">
<a href="{{ url_for('.fetch_list') }}" class="nav-link" role="menuitem"> <a href="{{ url_for('.fetch_list') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-download"></i> <i class="nav-icon fas fa-download"></i>
<p>{% trans %}Fetched accounts{% endtrans %}</p> <p>{% trans %}Fetched accounts{% endtrans %}</p>
</a> </a>
</li> </li>
{%- endif %}
<li class="nav-item" role="none"> <li class="nav-item" role="none">
<a href="{{ url_for('.token_list') }}" class="nav-link" role="menuitem"> <a href="{{ url_for('.token_list') }}" class="nav-link" role="menuitem">
<i class="nav-icon fas fa-ticket-alt"></i> <i class="nav-icon fas fa-ticket-alt"></i>

@ -16,7 +16,7 @@
{%- call macros.table() %} {%- call macros.table() %}
<thead> <thead>
<tr> <tr>
<th data-orderable="false">{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
<th>{% trans %}Comment{% endtrans %}</th> <th>{% trans %}Comment{% endtrans %}</th>
<th>{% trans %}Authorized IP{% endtrans %}</th> <th>{% trans %}Authorized IP{% endtrans %}</th>
<th>{% trans %}Created{% endtrans %}</th> <th>{% trans %}Created{% endtrans %}</th>
@ -31,8 +31,8 @@
</td> </td>
<td>{{ token.comment }}</td> <td>{{ token.comment }}</td>
<td>{{ token.ip or "any" }}</td> <td>{{ token.ip or "any" }}</td>
<td data-sort="{{ token.created_at or '0000-00-00' }}">{{ token.created_at | format_date }}</td> <td>{{ token.created_at | format_date }}</td>
<td data-sort="{{ token.updated_at or '0000-00-00' }}">{{ token.updated_at | format_date }}</td> <td>{{ token.updated_at | format_date }}</td>
</tr> </tr>
{%- endfor %} {%- endfor %}
</tbody> </tbody>

@ -21,11 +21,10 @@
{%- endcall %} {%- endcall %}
{%- call macros.card(_("Features and quotas"), theme="success") %} {%- call macros.card(_("Features and quotas"), theme="success") %}
{{ macros.form_field(form.quota_bytes, step=50*10**6, max=(max_quota_bytes or domain.max_quota_bytes or 50*10**9), data_infinity="true", {{ macros.form_field(form.quota_bytes, step=1000000000, max=(max_quota_bytes or domain.max_quota_bytes or 50*10**9), data_infinity="true",
prepend='<span class="input-group-text"><span id="quota_bytes_value"></span>&nbsp;GB</span>') }} prepend='<span class="input-group-text"><span id="quota_bytes_value"></span>&nbsp;GB</span>') }}
{{ macros.form_field(form.enable_imap) }} {{ macros.form_field(form.enable_imap) }}
{{ macros.form_field(form.enable_pop) }} {{ macros.form_field(form.enable_pop) }}
{{ macros.form_field(form.allow_spoofing) }}
{%- endcall %} {%- endcall %}
{{ macros.form_field(form.submit) }} {{ macros.form_field(form.submit) }}

@ -16,8 +16,8 @@
{%- call macros.table() %} {%- call macros.table() %}
<thead> <thead>
<tr> <tr>
<th data-orderable="false">{% trans %}Actions{% endtrans %}</th> <th>{% trans %}Actions{% endtrans %}</th>
<th data-orderable="false">{% trans %}User settings{% endtrans %}</th> <th>{% trans %}User settings{% endtrans %}</th>
<th>{% trans %}Email{% endtrans %}</th> <th>{% trans %}Email{% endtrans %}</th>
<th>{% trans %}Features{% endtrans %}</th> <th>{% trans %}Features{% endtrans %}</th>
<th>{% trans %}Quota{% endtrans %}</th> <th>{% trans %}Quota{% endtrans %}</th>
@ -39,15 +39,14 @@
<a href="{{ url_for('.fetch_list', user_email=user.email) }}" title="{% trans %}Fetched accounts{% endtrans %}"><i class="fa fa-download"></i></a>&nbsp; <a href="{{ url_for('.fetch_list', user_email=user.email) }}" title="{% trans %}Fetched accounts{% endtrans %}"><i class="fa fa-download"></i></a>&nbsp;
</td> </td>
<td>{{ user }}</td> <td>{{ user }}</td>
<td data-sort="{{ user.allow_spoofing*4 + user.enable_imap*2 + user.enable_pop }}"> <td>
{% if user.enable_imap %}<span class="badge bg-primary">imap</span>{% endif %} {% if user.enable_imap %}<span class="badge bg-info">imap</span>{% endif %}
{% if user.enable_pop %}<span class="badge bg-secondary">pop3</span>{% endif %} {% if user.enable_pop %}<span class="badge bg-info">pop3</span>{% endif %}
{% if user.allow_spoofing %}<span class="badge bg-danger">allow-spoofing</span>{% endif %}
</td> </td>
<td data-sort="{{ user.quota_bytes_used }}">{{ user.quota_bytes_used | filesizeformat }} / {{ (user.quota_bytes | filesizeformat) if user.quota_bytes else '∞' }}</td> <td>{{ user.quota_bytes_used | filesizeformat }} / {{ (user.quota_bytes | filesizeformat) if user.quota_bytes else '∞' }}</td>
<td>{{ user.comment or '-' }}</td> <td>{{ user.comment or '-' }}</td>
<td data-sort="{{ user.created_at or '0000-00-00' }}">{{ user.created_at | format_date }}</td> <td>{{ user.created_at | format_date }}</td>
<td data-sort="{{ user.updated_at or '0000-00-00' }}">{{ user.updated_at | format_date }}</td> <td>{{ user.updated_at | format_date }}</td>
</tr> </tr>
{%- endfor %} {%- endfor %}
</tbody> </tbody>

@ -9,22 +9,18 @@
{%- endblock %} {%- endblock %}
{%- block content %} {%- block content %}
{%- call macros.table(order='[[1,"asc"]]') %} {%- call macros.table() %}
<thead> <tr>
<tr>
<th>{% trans %}Domain{% endtrans %}</th> <th>{% trans %}Domain{% endtrans %}</th>
<th>{% trans %}Available slots{% endtrans %}</th> <th>{% trans %}Available slots{% endtrans %}</th>
<th>{% trans %}Quota{% endtrans %}</th> <th>{% trans %}Quota{% endtrans %}</th>
</tr> </tr>
</thead>
<tbody>
{%- for domain_name, domain in available_domains.items() %} {%- for domain_name, domain in available_domains.items() %}
<tr> <tr>
<td><a href="{{ url_for('.user_signup', domain_name=domain_name) }}">{{ domain_name }}</a></td> <td><a href="{{ url_for('.user_signup', domain_name=domain_name) }}">{{ domain_name }}</a></td>
<td data-sort="{{ -1 if domain.max_users == -1 else domain.max_users - (domain.users | count)}}">{{ '∞' if domain.max_users == -1 else domain.max_users - (domain.users | count)}}</td> <td>{{ '∞' if domain.max_users == -1 else domain.max_users - (domain.users | count)}}</td>
<td data-sort="{{ domain.max_quota_bytes or config['DEFAULT_QUOTA'] }}">{{ domain.max_quota_bytes or config['DEFAULT_QUOTA'] | filesizeformat }}</td> <td>{{ domain.max_quota_bytes or config['DEFAULT_QUOTA'] | filesizeformat }}</td>
</tr> </tr>
{%- endfor %} {%- endfor %}
</tbody>
{%- endcall %} {%- endcall %}
{%- endblock %} {%- endblock %}

@ -21,9 +21,8 @@ def announcement():
form = forms.AnnouncementForm() form = forms.AnnouncementForm()
if form.validate_on_submit(): if form.validate_on_submit():
for user in models.User.query.all(): for user in models.User.query.all():
if not user.sendmail(form.announcement_subject.data, user.sendmail(form.announcement_subject.data,
form.announcement_body.data): form.announcement_body.data)
flask.flash('Failed to send to %s' % user.email, 'error')
# Force-empty the form # Force-empty the form
form.announcement_subject.data = '' form.announcement_subject.data = ''
form.announcement_body.data = '' form.announcement_body.data = ''

@ -1,4 +1,4 @@
from mailu import models, utils from mailu import models
from mailu.ui import ui, forms, access from mailu.ui import ui, forms, access
from flask import current_app as app from flask import current_app as app
@ -93,9 +93,6 @@ def domain_signup(domain_name=None):
del form.pw del form.pw
del form.pw2 del form.pw2
if form.validate_on_submit(): if form.validate_on_submit():
if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error")
return flask.render_template('domain/signup.html', form=form)
conflicting_domain = models.Domain.query.get(form.name.data) conflicting_domain = models.Domain.query.get(form.name.data)
conflicting_alternative = models.Alternative.query.get(form.name.data) conflicting_alternative = models.Alternative.query.get(form.name.data)
conflicting_relay = models.Relay.query.get(form.name.data) conflicting_relay = models.Relay.query.get(form.name.data)

@ -1,6 +1,5 @@
from mailu import models, utils from mailu import models
from mailu.ui import ui, forms, access from mailu.ui import ui, forms, access
from flask import current_app as app
import flask import flask
import flask_login import flask_login
@ -11,8 +10,6 @@ import wtforms
@ui.route('/fetch/list/<path:user_email>', methods=['GET']) @ui.route('/fetch/list/<path:user_email>', methods=['GET'])
@access.owner(models.User, 'user_email') @access.owner(models.User, 'user_email')
def fetch_list(user_email): def fetch_list(user_email):
if not app.config['FETCHMAIL_ENABLED']:
flask.abort(404)
user_email = user_email or flask_login.current_user.email user_email = user_email or flask_login.current_user.email
user = models.User.query.get(user_email) or flask.abort(404) user = models.User.query.get(user_email) or flask.abort(404)
return flask.render_template('fetch/list.html', user=user) return flask.render_template('fetch/list.html', user=user)
@ -22,18 +19,13 @@ def fetch_list(user_email):
@ui.route('/fetch/create/<path:user_email>', methods=['GET', 'POST']) @ui.route('/fetch/create/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email') @access.owner(models.User, 'user_email')
def fetch_create(user_email): def fetch_create(user_email):
if not app.config['FETCHMAIL_ENABLED']:
flask.abort(404)
user_email = user_email or flask_login.current_user.email user_email = user_email or flask_login.current_user.email
user = models.User.query.get(user_email) or flask.abort(404) user = models.User.query.get(user_email) or flask.abort(404)
form = forms.FetchForm() form = forms.FetchForm()
form.password.validators = [wtforms.validators.DataRequired()] form.password.validators = [wtforms.validators.DataRequired()]
utils.formatCSVField(form.folders)
if form.validate_on_submit(): if form.validate_on_submit():
fetch = models.Fetch(user=user) fetch = models.Fetch(user=user)
form.populate_obj(fetch) form.populate_obj(fetch)
if form.folders.data:
fetch.folders = form.folders.data.replace(' ','').split(',')
models.db.session.add(fetch) models.db.session.add(fetch)
models.db.session.commit() models.db.session.commit()
flask.flash('Fetch configuration created') flask.flash('Fetch configuration created')
@ -45,17 +37,12 @@ def fetch_create(user_email):
@ui.route('/fetch/edit/<fetch_id>', methods=['GET', 'POST']) @ui.route('/fetch/edit/<fetch_id>', methods=['GET', 'POST'])
@access.owner(models.Fetch, 'fetch_id') @access.owner(models.Fetch, 'fetch_id')
def fetch_edit(fetch_id): def fetch_edit(fetch_id):
if not app.config['FETCHMAIL_ENABLED']:
flask.abort(404)
fetch = models.Fetch.query.get(fetch_id) or flask.abort(404) fetch = models.Fetch.query.get(fetch_id) or flask.abort(404)
form = forms.FetchForm(obj=fetch) form = forms.FetchForm(obj=fetch)
utils.formatCSVField(form.folders)
if form.validate_on_submit(): if form.validate_on_submit():
if not form.password.data: if not form.password.data:
form.password.data = fetch.password form.password.data = fetch.password
form.populate_obj(fetch) form.populate_obj(fetch)
if form.folders.data:
fetch.folders = form.folders.data.replace(' ','').split(',')
models.db.session.commit() models.db.session.commit()
flask.flash('Fetch configuration updated') flask.flash('Fetch configuration updated')
return flask.redirect( return flask.redirect(
@ -68,8 +55,6 @@ def fetch_edit(fetch_id):
@access.confirmation_required("delete a fetched account") @access.confirmation_required("delete a fetched account")
@access.owner(models.Fetch, 'fetch_id') @access.owner(models.Fetch, 'fetch_id')
def fetch_delete(fetch_id): def fetch_delete(fetch_id):
if not app.config['FETCHMAIL_ENABLED']:
flask.abort(404)
fetch = models.Fetch.query.get(fetch_id) or flask.abort(404) fetch = models.Fetch.query.get(fetch_id) or flask.abort(404)
user = fetch.user user = fetch.user
models.db.session.delete(fetch) models.db.session.delete(fetch)

@ -1,4 +1,4 @@
from mailu import models, utils from mailu import models
from mailu.ui import ui, access, forms from mailu.ui import ui, access, forms
from flask import current_app as app from flask import current_app as app
@ -28,10 +28,6 @@ def user_create(domain_name):
form.quota_bytes.validators = [ form.quota_bytes.validators = [
wtforms.validators.NumberRange(max=domain.max_quota_bytes)] wtforms.validators.NumberRange(max=domain.max_quota_bytes)]
if form.validate_on_submit(): if form.validate_on_submit():
if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error")
return flask.render_template('user/create.html',
domain=domain, form=form)
if domain.has_email(form.localpart.data): if domain.has_email(form.localpart.data):
flask.flash('Email is already used', 'error') flask.flash('Email is already used', 'error')
else: else:
@ -64,11 +60,6 @@ def user_edit(user_email):
form.quota_bytes.validators = [ form.quota_bytes.validators = [
wtforms.validators.NumberRange(max=max_quota_bytes)] wtforms.validators.NumberRange(max=max_quota_bytes)]
if form.validate_on_submit(): if form.validate_on_submit():
if form.pw.data:
if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error")
return flask.render_template('user/edit.html', form=form, user=user,
domain=user.domain, max_quota_bytes=max_quota_bytes)
form.populate_obj(user) form.populate_obj(user)
if form.pw.data: if form.pw.data:
user.set_password(form.pw.data) user.set_password(form.pw.data)
@ -100,7 +91,11 @@ def user_settings(user_email):
user_email_or_current = user_email or flask_login.current_user.email user_email_or_current = user_email or flask_login.current_user.email
user = models.User.query.get(user_email_or_current) or flask.abort(404) user = models.User.query.get(user_email_or_current) or flask.abort(404)
form = forms.UserSettingsForm(obj=user) form = forms.UserSettingsForm(obj=user)
utils.formatCSVField(form.forward_destination) if isinstance(form.forward_destination.data,str):
data = form.forward_destination.data.replace(" ","").split(",")
else:
data = form.forward_destination.data
form.forward_destination.data = ", ".join(data)
if form.validate_on_submit(): if form.validate_on_submit():
form.forward_destination.data = form.forward_destination.data.replace(" ","").split(",") form.forward_destination.data = form.forward_destination.data.replace(" ","").split(",")
form.populate_obj(user) form.populate_obj(user)
@ -124,9 +119,6 @@ def user_password(user_email):
if form.pw.data != form.pw2.data: if form.pw.data != form.pw2.data:
flask.flash('Passwords do not match', 'error') flask.flash('Passwords do not match', 'error')
else: else:
if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error")
return flask.render_template('user/password.html', form=form, user=user)
flask.session.regenerate() flask.session.regenerate()
user.set_password(form.pw.data) user.set_password(form.pw.data)
models.db.session.commit() models.db.session.commit()
@ -178,9 +170,6 @@ def user_signup(domain_name=None):
if domain.has_email(form.localpart.data) or models.Alias.resolve(form.localpart.data, domain_name): if domain.has_email(form.localpart.data) or models.Alias.resolve(form.localpart.data, domain_name):
flask.flash('Email is already used', 'error') flask.flash('Email is already used', 'error')
else: else:
if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error")
return flask.render_template('user/signup.html', domain=domain, form=form)
flask.session.regenerate() flask.session.regenerate()
user = models.User(domain=domain) user = models.User(domain=domain)
form.populate_obj(user) form.populate_obj(user)

@ -472,7 +472,7 @@ class MailuSessionExtension:
redis.StrictRedis().from_url(app.config['SESSION_STORAGE_URL']) redis.StrictRedis().from_url(app.config['SESSION_STORAGE_URL'])
) )
# clean expired sessions once on first use in case lifetime was changed # clean expired sessions oonce on first use in case lifetime was changed
def cleaner(): def cleaner():
with cleaned.get_lock(): with cleaned.get_lock():
if not cleaned.value: if not cleaned.value:
@ -507,21 +507,3 @@ def gen_temp_token(email, session):
app.config['PERMANENT_SESSION_LIFETIME'], app.config['PERMANENT_SESSION_LIFETIME'],
) )
return token return token
def isBadOrPwned(form):
try:
if len(form.pw.data) < 8:
return "This password is too short."
breaches = int(form.pwned.data)
except ValueError:
breaches = -1
if breaches > 0:
return f"This password appears in {breaches} data breaches! It is not unique; please change it."
return None
def formatCSVField(field):
if isinstance(field.data,str):
data = field.data.replace(" ","").split(",")
else:
data = field.data
field.data = ", ".join(data)

@ -647,7 +647,7 @@ msgid "New relay domain"
msgstr "" msgstr ""
#: mailu/ui/templates/relay/edit.html:4 #: mailu/ui/templates/relay/edit.html:4
msgid "Edit relayed domain" msgid "Edit relayd domain"
msgstr "" msgstr ""
#: mailu/ui/templates/relay/list.html:4 #: mailu/ui/templates/relay/list.html:4

@ -1,22 +0,0 @@
""" Add user.allow_spoofing
Revision ID: 7ac252f2bbbf
Revises: 8f9ea78776f4
Create Date: 2022-11-20 08:57:16.879152
"""
# revision identifiers, used by Alembic.
revision = '7ac252f2bbbf'
down_revision = 'f4f0f89e0047'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('user', sa.Column('allow_spoofing', sa.Boolean(), nullable=False, server_default=sa.sql.expression.false()))
def downgrade():
op.drop_column('user', 'allow_spoofing')

@ -1,25 +0,0 @@
""" Add fetch.scan and fetch.folders
Revision ID: f4f0f89e0047
Revises: 8f9ea78776f4
Create Date: 2022-11-13 16:29:01.246509
"""
# revision identifiers, used by Alembic.
revision = 'f4f0f89e0047'
down_revision = '8f9ea78776f4'
from alembic import op
import sqlalchemy as sa
import mailu
def upgrade():
with op.batch_alter_table('fetch') as batch:
batch.add_column(sa.Column('scan', sa.Boolean(), nullable=False, server_default=sa.sql.expression.false()))
batch.add_column(sa.Column('folders', mailu.models.CommaSeparatedList(), nullable=True))
def downgrade():
with op.batch_alter_table('fetch') as batch:
batch.drop_column('fetch', 'folders')
batch.drop_column('fetch', 'scan')

@ -0,0 +1,78 @@
alembic==1.7.4
appdirs==1.4.4
Babel==2.9.1
bcrypt==3.2.0
blinker==1.4
CacheControl==0.12.9
certifi==2021.10.8
# cffi==1.15.0
chardet==4.0.0
click==8.0.3
colorama==0.4.4
contextlib2==21.6.0
cryptography==35.0.0
decorator==5.1.0
# distlib==0.3.1
# distro==1.5.0
dnspython==2.1.0
dominate==2.6.0
email-validator==1.1.3
Flask==2.0.2
Flask-Babel==2.0.0
Flask-Bootstrap==3.3.7.1
Flask-DebugToolbar==0.11.0
Flask-Limiter==1.4
Flask-Login==0.5.0
flask-marshmallow==0.14.0
Flask-Migrate==3.1.0
Flask-Script==2.0.6
Flask-SQLAlchemy==2.5.1
Flask-WTF==0.15.1
greenlet==1.1.2
gunicorn==20.1.0
html5lib==1.1
idna==3.3
infinity==1.5
intervals==0.9.2
itsdangerous==2.0.1
Jinja2==3.0.2
limits==1.5.1
lockfile==0.12.2
Mako==1.1.5
MarkupSafe==2.0.1
marshmallow==3.14.0
marshmallow-sqlalchemy==0.26.1
msgpack==1.0.2
# mysqlclient==2.0.3
mysql-connector-python==8.0.25
ordered-set==4.0.2
# packaging==20.9
passlib==1.7.4
# pep517==0.10.0
progress==1.6
#psycopg2==2.9.1
psycopg2-binary==2.9.3
pycparser==2.20
Pygments==2.10.0
pyOpenSSL==21.0.0
pyparsing==3.0.4
pytz==2021.3
PyYAML==6.0
redis==3.5.3
requests==2.26.0
retrying==1.3.3
# six==1.15.0
socrate==0.2.0
SQLAlchemy==1.4.26
srslib==0.1.4
tabulate==0.8.9
tenacity==8.0.1
toml==0.10.2
urllib3==1.26.7
validators==0.18.2
visitor==0.1.3
webencodings==0.5.1
Werkzeug==2.0.2
WTForms==2.3.3
WTForms-Components==0.10.5
xmltodict==0.12.0

@ -0,0 +1,28 @@
Flask
Flask-Login
Flask-SQLAlchemy
Flask-bootstrap
Flask-Babel
Flask-migrate
Flask-script
Flask-wtf
Flask-debugtoolbar
limits
redis
WTForms-Components
socrate
passlib
gunicorn
tabulate
PyYAML
PyOpenSSL
Pygments
dnspython
tenacity
mysql-connector-python
idna
srslib
marshmallow
flask-marshmallow
marshmallow-sqlalchemy
xmltodict

@ -1,141 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
### CONFIG
DEV_NAME="${DEV_NAME:-mailu-dev}"
DEV_DB="${DEV_DB:-}"
DEV_PROFILER="${DEV_PROFILER:-false}"
DEV_LISTEN="${DEV_LISTEN:-127.0.0.1:8080}"
[[ "${DEV_LISTEN}" == *:* ]] || DEV_LISTEN="127.0.0.1:${DEV_LISTEN}"
DEV_ADMIN="${DEV_ADMIN:-admin@example.com}"
DEV_PASSWORD="${DEV_PASSWORD:-letmein}"
### MAIN
[[ -n "${DEV_DB}" ]] && {
[[ -f "${DEV_DB}" ]] || {
echo "Sorry, can't find DEV_DB: '${DEV_DB}'"
exit 1
}
DEV_DB="$(realpath "${DEV_DB}")"
}
docker="$(command -v podman || command -v docker || echo false)"
[[ "${docker}" == "false" ]] && {
echo "Sorry, you'll need podman or docker to run this."
exit 1
}
tmp="$(mktemp -d)"
[[ -n "${tmp}" && -d "${tmp}" ]] || {
echo "Sorry, can't create temporary folder."
exit 1
}
trap "rm -rf '${tmp}'" INT TERM EXIT
admin="$(realpath "$(pwd)/${0%/*}")"
base="${admin}/../base"
assets="${admin}/assets"
cd "${tmp}"
# base
cp "${base}"/requirements-* .
cp -r "${base}"/libs .
sed -E '/^#/d;s:^FROM system$:FROM system AS base:' "${base}/Dockerfile" >Dockerfile
# assets
cp "${assets}/package.json" .
cp -r "${assets}/assets" ./assets
awk '/new compress/{f=1}!f{print}/}),/{f=0}' <"${assets}/webpack.config.js" >webpack.config.js
sed -E '/^#/d;s:^(FROM [^ ]+$):\1 AS assets:' "${assets}/Dockerfile" >>Dockerfile
# admin
sed -E '/^#/d;/^(COPY|EXPOSE|HEALTHCHECK|VOLUME|CMD) /d; s:^(.* )[^ ]*pybabel[^\\]*(.*):\1true \2:' "${admin}/Dockerfile" >>Dockerfile
# development
cat >>Dockerfile <<EOF
COPY --from=assets /work/static/ ./static/
RUN set -euxo pipefail \
; mkdir /data \
; ln -s /app/audit.py / \
; ln -s /app/start.py /
ENV \
FLASK_DEBUG="true" \
MEMORY_SESSIONS="true" \
RATELIMIT_STORAGE_URL="memory://" \
SESSION_COOKIE_SECURE="false" \
\
DEBUG="true" \
DEBUG_PROFILER="${DEV_PROFILER}" \
DEBUG_ASSETS="/app/static" \
DEBUG_TB_INTERCEPT_REDIRECTS=False \
\
ADMIN_ADDRESS="127.0.0.1" \
FRONT_ADDRESS="127.0.0.1" \
SMTP_ADDRESS="127.0.0.1" \
IMAP_ADDRESS="127.0.0.1" \
REDIS_ADDRESS="127.0.0.1" \
ANTIVIRUS_ADDRESS="127.0.0.1" \
ANTISPAM_ADDRESS="127.0.0.1" \
WEBMAIL_ADDRESS="127.0.0.1" \
WEBDAV_ADDRESS="127.0.0.1"
CMD ["/bin/bash", "-c", "flask db upgrade &>/dev/null && flask mailu admin '${DEV_ADMIN/@*}' '${DEV_ADMIN#*@}' '${DEV_PASSWORD}' --mode ifmissing >/dev/null; flask --debug run --host=0.0.0.0 --port=8080"]
EOF
# build
chmod -R u+rwX,go+rX .
"${docker}" build --tag "${DEV_NAME}:latest" .
# gather volumes to map into container
volumes=()
[[ -n "${DEV_DB}" ]] && volumes+=( --volume "${DEV_DB}:/data/main.db" )
for vol in audit.py start.py mailu/ migrations/; do
volumes+=( --volume "${admin}/${vol}:/app/${vol}" )
done
for file in "${assets}/assets"/*; do
[[ ! -f "${file}" || "${file}" == */vendor.js ]] && continue
volumes+=( --volume "${file}:/app/static/${file/*\//}" )
done
# show configuration
cat <<EOF
=============================================================================
The "${DEV_NAME}" container was built using this configuration:
DEV_NAME="${DEV_NAME}"
DEV_DB="${DEV_DB}"
DEV_PROFILER="${DEV_PROFILER}"
DEV_LISTEN="${DEV_LISTEN}"
DEV_ADMIN="${DEV_ADMIN}"
DEV_PASSWORD="${DEV_PASSWORD}"
=============================================================================
=============================================================================
You can start the container later using this commandline:
${docker/*\/} run --rm -it --name "${DEV_NAME}" --publish ${DEV_LISTEN}:8080$(printf " %q" "${volumes[@]}") "${DEV_NAME}"
=============================================================================
=============================================================================
The Mailu UI can be found here: http://${DEV_LISTEN}/sso/login
EOF
[[ -z "${DEV_DB}" ]] && echo "You can log in with user ${DEV_ADMIN} and password ${DEV_PASSWORD}"
cat <<EOF
=============================================================================
Starting mailu dev environment...
EOF
# run
"${docker}" run --rm -it --name "${DEV_NAME}" --publish "${DEV_LISTEN}:8080" "${volumes[@]}" "${DEV_NAME}"

@ -1,19 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/python3
import os import os
import logging as log import logging as log
from pwd import getpwnam
import sys import sys
from socrate import system
os.system("chown mailu:mailu -R /dkim")
os.system("find /data | grep -v /fetchmail | xargs -n1 chown mailu:mailu")
mailu_id = getpwnam('mailu')
os.setgid(mailu_id.pw_gid)
os.setuid(mailu_id.pw_uid)
log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "INFO")) log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "INFO"))
system.set_env(['SECRET'])
os.system("flask mailu advertise") os.system("flask mailu advertise")
os.system("flask db upgrade") os.system("flask db upgrade")
@ -24,7 +15,7 @@ password = os.environ.get("INITIAL_ADMIN_PW")
if account is not None and domain is not None and password is not None: if account is not None and domain is not None and password is not None:
mode = os.environ.get("INITIAL_ADMIN_MODE", default="ifmissing") mode = os.environ.get("INITIAL_ADMIN_MODE", default="ifmissing")
log.info("Creating initial admin account %s@%s with mode %s", account, domain, mode) log.info("Creating initial admin accout %s@%s with mode %s",account,domain,mode)
os.system("flask mailu admin %s %s '%s' --mode %s" % (account, domain, password, mode)) os.system("flask mailu admin %s %s '%s' --mode %s" % (account, domain, password, mode))
def test_DNS(): def test_DNS():
@ -46,7 +37,7 @@ def test_DNS():
try: try:
result = resolver.resolve('example.org', dns.rdatatype.A, dns.rdataclass.IN, lifetime=10) result = resolver.resolve('example.org', dns.rdatatype.A, dns.rdataclass.IN, lifetime=10)
except Exception as e: except Exception as e:
log.critical("Your DNS resolver at %s is not working (%s). Please see https://mailu.io/master/faq.html#the-admin-container-won-t-start-and-its-log-says-critical-your-dns-resolver-isn-t-doing-dnssec-validation", ns, e) log.critical("Your DNS resolver at %s is not working (%s). Please see https://mailu.io/master/faq.html#the-admin-container-won-t-start-and-its-log-says-critical-your-dns-resolver-isn-t-doing-dnssec-validation", ns, e);
else: else:
if result.response.flags & dns.flags.AD: if result.response.flags & dns.flags.AD:
break break

@ -9,7 +9,7 @@ module.exports = {
mode: 'production', mode: 'production',
entry: { entry: {
app: { app: {
import: ['./assets/app.css', './assets/mailu.png', './assets/app.js'], import: './assets/app.js',
dependOn: 'vendor', dependOn: 'vendor',
}, },
vendor: './assets/vendor.js', vendor: './assets/vendor.js',

@ -1,86 +0,0 @@
# syntax=docker/dockerfile-upstream:1.4.3
# base system image (intermediate)
ARG DISTRO=alpine:3.16.3
FROM $DISTRO as system
ENV TZ=Etc/UTC LANG=C.UTF-8
ARG MAILU_UID=1000
ARG MAILU_GID=1000
RUN set -euxo pipefail \
; addgroup -Sg ${MAILU_GID} mailu \
; adduser -Sg ${MAILU_UID} -G mailu -h /app -g "mailu app" -s /bin/bash mailu \
; apk add --no-cache bash ca-certificates curl python3 tzdata libcap \
; machine="$(uname -m)" \
; ! [[ "${machine}" == x86_64 ]] \
|| apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing hardened-malloc==11-r0
ENV LD_PRELOAD=/usr/lib/libhardened_malloc.so
ENV CXXFLAGS="-g -O2 -fdebug-prefix-map=/app=. -fstack-protector-strong -Wformat -Werror=format-security -fstack-clash-protection -fexceptions"
ENV CFLAGS="-g -O2 -fdebug-prefix-map=/app=. -fstack-protector-strong -Wformat -Werror=format-security -fstack-clash-protection -fexceptions"
ENV CPPFLAGS="-Wdate-time -D_FORTIFY_SOURCE=2"
ENV LDFLAGS="-Wl,-z,noexecstack -Wl,-z,relro -Wl,-z,now"
WORKDIR /app
CMD /bin/bash
# build virtual env (intermediate)
FROM system as build
ARG MAILU_DEPS=prod
ENV VIRTUAL_ENV=/app/venv
COPY requirements-build.txt ./
RUN set -euxo pipefail \
; apk add --no-cache py3-pip \
; python3 -m venv ${VIRTUAL_ENV} \
; ${VIRTUAL_ENV}/bin/pip install --no-cache-dir -r requirements-build.txt \
; apk del -r py3-pip \
; rm -f /tmp/*.pem
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
COPY requirements-${MAILU_DEPS}.txt ./
COPY libs/ libs/
ARG SNUFFLEUPAGUS_VERSION=0.8.3
ENV SNUFFLEUPAGUS_URL https://github.com/jvoisin/snuffleupagus/archive/refs/tags/v$SNUFFLEUPAGUS_VERSION.tar.gz
RUN set -euxo pipefail \
; machine="$(uname -m)" \
; deps="build-base gcc libffi-dev python3-dev" \
; [[ "${machine}" != x86_64 ]] && \
deps="${deps} cargo git libressl-dev mariadb-connector-c-dev postgresql-dev" \
; apk add --virtual .build-deps ${deps} \
; [[ "${machine}" == armv7* ]] && \
mkdir -p /root/.cargo/registry/index && \
git clone --bare https://github.com/rust-lang/crates.io-index.git /root/.cargo/registry/index/github.com-1285ae84e5963aae \
; pip install -r requirements-${MAILU_DEPS}.txt \
; curl -sL ${SNUFFLEUPAGUS_URL} | tar xz \
; cd snuffleupagus-$SNUFFLEUPAGUS_VERSION \
; rm -rf src/tests/*php7*/ src/tests/*session*/ src/tests/broken_configuration/ src/tests/*cookie* src/tests/upload_validation/ \
; apk add --virtual .build-deps php81-dev php81-cgi php81-simplexml php81-xml pcre-dev build-base php81-pear php81-openssl re2c \
; ln -s /usr/bin/phpize81 /usr/bin/phpize \
; ln -s /usr/bin/pecl81 /usr/bin/pecl \
; ln -s /usr/bin/php-config81 /usr/bin/php-config \
; ln -s /usr/bin/php81 /usr/bin/php \
; pecl install vld-beta \
; make -j $(grep -c processor /proc/cpuinfo) release \
; cp src/.libs/snuffleupagus.so /app \
; rm -rf /root/.cargo /tmp/*.pem /root/.cache
# base mailu image
FROM system
COPY --from=build /app/venv/ /app/venv/
COPY --chown=root:root --from=build /app/snuffleupagus.so /usr/lib/php81/modules/
RUN setcap 'cap_net_bind_service=+ep' /app/venv/bin/gunicorn
ENV VIRTUAL_ENV=/app/venv
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"

@ -1,20 +0,0 @@
.DS_Store
.idea
tmp
*.bak
*~
.*.swp
__pycache__/
*.pyc
*.pyo
*.egg-info/
.build
.env*
.venv
*.code-workspace
build/

@ -1,7 +0,0 @@
This project is open source, and your contributions are all welcome. There are mostly three different ways one can contribute to the project:
1. use Podop, either on test or on production servers, and report meaningful bugs when you find some;
2. write and publish, or contribute to mail distributions based on Podop, like Mailu;
2. contribute code and/or configuration to the repository (see [the development guidelines](https://mailu.io/contributors/guide.html) for details);
Either way, keep in mind that the code you write must be licensed under the same conditions as the project itself. Additionally, all contributors are considered equal co-authors of the project.

@ -1,25 +0,0 @@
MIT License
Copyright (c) 2018 All Podop contributors at the date
This software consists of voluntary contributions made by multiple individuals.
For exact contribution history, see the revision history available at
https://github.com/Mailu/podop.git
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -1,2 +0,0 @@
include README.md
include LICENSE.md

@ -1,112 +0,0 @@
Podop is a piece of middleware designed to run between Postfix or Dovecot
on one side, any Python implementation of a table lookup protocol on the
other side.
It is thus able to forward Postfix maps and Dovecot dicts to the same
(or multiple) backends in order to write a single, more flexible backend
for a mail distribution.
Examples
========
- Connect Postfix to a DNS lookup so that every domain that has a proper MX
record to your Postfix is actually accepted as a local domain
- Connect both Postfix and Dovecot to an HTTP microservice to run a high
availability microservice-based mail service
- Use a single database server running any Python-compatible API for both
your Postfix and Dovecot servers
Configure Podop tables
======================
Podop tables are configured through CLI arguments when running the server.
You must provide a ``--name`` for the table, a ``--type`` for the table and
a ``--param`` that parametrizes the map.
URL table
---------
The URL table will initiate an HTTP GET request for read access and an HTTP
POST request for write access to a table. The table is parametrized with
a template URL containing ``§`` (or ``{}``) for inserting the table key.
```
--name test --type url --param http://microservice/api/v1/map/tests/§
```
GET requests should return ``200`` and a JSON-encoded object
that will be passed either to Postfix or Dovecot. They should return ``4XX``
for access issues that will result in lookup miss, and ``5XX`` for backend
issues that will result in a temporary failure.
POST requests will contain a JSON-encoded object in the request body, that
will be saved in the table.
Postfix usage
=============
In order to access Podop tables from Postfix, you should setup ``socketmap``
Postfix maps. For instance, in order to access the ``test`` table on a Podop
socket at ``/tmp/podop.socket``, use the following setup:
```
virtual_alias_maps = socketmap:unix:/tmp/podop.socket:test
```
Multiple maps or identical maps can be configured for various usages.
```
virtual_alias_maps = socketmap:unix:/tmp/podop.socket:alias
virtual_mailbox_domains = socketmap:unix:/tmp/podop.socket:domain
virtual_mailbox_maps = socketmap:unix:/tmp/podop.socket:alias
```
In order to simplify the configuration, you can setup a shortcut.
```
podop = socketmap:unix:/tmp/podop.socket
virtual_alias_maps = ${podop}:alias
virtual_mailbox_domains = ${podop}:domain
virtual_mailbox_maps = ${podop}:alias
```
Dovecot usage
=============
In order to access Podop tables from Dovecot, you should setup a ``proxy``
Dovecot dictionary. For instance, in order to access the ``test`` table on
a Podop socket at ``/tmp/podop.socket``, use the following setup:
```
mail_attribute_dict = proxy:/tmp/podop.socket:test
```
Multiple maps or identical maps can be configured for various usages.
```
mail_attribute_dict = proxy:/tmp/podop.socket:meta
passdb {
driver = dict
args = /etc/dovecot/auth.conf
}
userdb {
driver = dict
args = /etc/dovecot/auth.conf
}
# then in auth.conf
uri = proxy:/tmp/podop.socket:auth
iterate_disable = yes
default_pass_scheme = plain
password_key = passdb/%u
user_key = userdb/%u
```
Contributing
============
Podop is free software, open to suggestions and contributions. All
components are free software and compatible with the MIT license. All
the code is placed under the MIT license.

@ -1,46 +0,0 @@
""" Podop is a *Po*stfix and *Do*vecot proxy
It is able to proxify postfix maps and dovecot dicts to any table
"""
import asyncio
import logging
import sys
from podop import postfix, dovecot, table
SERVER_TYPES = dict(
postfix=postfix.SocketmapProtocol,
dovecot=dovecot.DictProtocol
)
TABLE_TYPES = dict(
url=table.UrlTable
)
def run_server(verbosity, server_type, socket, tables):
""" Run the server, given its type, socket path and table list
The table list must be a list of tuples (name, type, param)
"""
# Prepare the maps
table_map = {
name: TABLE_TYPES[table_type](param)
for name, table_type, param in tables
}
# Run the main loop
logging.basicConfig(stream=sys.stderr, level=max(3 - verbosity, 0) * 10,
format='%(name)s (%(levelname)s): %(message)s')
loop = asyncio.get_event_loop()
server = loop.run_until_complete(loop.create_unix_server(
SERVER_TYPES[server_type].factory(table_map), socket
))
try:
loop.run_forever()
except KeyboardInterrupt:
pass
server.close()
loop.run_until_complete(server.wait_closed())
loop.close()

@ -1,202 +0,0 @@
""" Dovecot dict proxy implementation
"""
import asyncio
import logging
import json
class DictProtocol(asyncio.Protocol):
""" Protocol to answer Dovecot dict requests, as implemented in Dict proxy.
Only a subset of operations is handled properly by this proxy: hello,
lookup and transaction-based set.
There is very little documentation about the protocol, most of it was
reverse-engineered from :
https://github.com/dovecot/core/blob/master/src/dict/dict-connection.c
https://github.com/dovecot/core/blob/master/src/dict/dict-commands.c
https://github.com/dovecot/core/blob/master/src/lib-dict/dict-client.h
"""
DATA_TYPES = {0: str, 1: int}
def __init__(self, table_map):
self.table_map = table_map
# Minor and major versions are not properly checked yet, but stored
# anyway
self.major_version = None
self.minor_version = None
# Every connection starts with specifying which table is used, dovecot
# tables are called dicts
self.dict = None
# Dictionary of active transaction lists per transaction id
self.transactions = {}
# Dictionary of user per transaction id
self.transactions_user = {}
super(DictProtocol, self).__init__()
def connection_made(self, transport):
logging.info('Connect {}'.format(transport.get_extra_info('peername')))
self.transport = transport
self.transport_lock = asyncio.Lock()
def data_received(self, data):
logging.debug("Received {}".format(data))
results = []
# Every command is separated by "\n"
for line in data.split(b"\n"):
# A command must at list have a type and one argument
if len(line) < 2:
continue
# The command function will handle the command itself
command = DictProtocol.COMMANDS.get(line[0])
if command is None:
logging.warning('Unknown command {}'.format(line[0]))
return self.transport.abort()
# Args are separated by "\t"
args = line[1:].strip().split(b"\t")
try:
future = command(self, *args)
if future:
results.append(future)
except Exception:
logging.exception("Error when processing request")
return self.transport.abort()
# For asyncio consistency, wait for all results to fire before
# actually returning control
return asyncio.gather(*results)
def process_hello(self, major, minor, value_type, user, dict_name):
""" Process a dict protocol hello message
"""
self.major, self.minor = int(major), int(minor)
self.value_type = DictProtocol.DATA_TYPES[int(value_type)]
self.user = user.decode("utf8")
self.dict = self.table_map[dict_name.decode("ascii")]
logging.debug("Client {}.{} type {}, user {}, dict {}".format(
self.major, self.minor, self.value_type, self.user, dict_name))
async def process_lookup(self, key, user=None, is_iter=False):
""" Process a dict lookup message
"""
logging.debug("Looking up {} for {}".format(key, user))
orig_key = key
# Priv and shared keys are handled slighlty differently
key_type, key = key.decode("utf8").split("/", 1)
try:
result = await self.dict.get(
key, ns=((user.decode("utf8") if user else self.user) if key_type == "priv" else None)
)
if type(result) is str:
response = result.encode("utf8")
elif type(result) is bytes:
response = result
else:
response = json.dumps(result).encode("ascii")
return await (self.reply(b"O", orig_key, response) if is_iter else self.reply(b"O", response))
except KeyError:
return await self.reply(b"N")
async def process_iterate(self, flags, max_rows, path, user=None):
""" Process an iterate command
"""
logging.debug("Iterate flags {} max_rows {} on {} for {}".format(flags, max_rows, path, user))
# Priv and shared keys are handled slighlty differently
key_type, key = path.decode("utf8").split("/", 1)
max_rows = int(max_rows.decode("utf-8"))
flags = int(flags.decode("utf-8"))
if flags != 0: # not implemented
return await self.reply(b"F")
rows = []
try:
result = await self.dict.iter(key)
logging.debug("Found {} entries: {}".format(len(result), result))
for i,k in enumerate(result):
if max_rows > 0 and i >= max_rows:
break
rows.append(self.process_lookup((path.decode("utf8")+k).encode("utf8"), user, is_iter=True))
await asyncio.gather(*rows)
async with self.transport_lock:
self.transport.write(b"\n") # ITER_FINISHED
return
except KeyError:
return await self.reply(b"F")
except Exception as e:
for task in rows:
task.cancel()
raise e
def process_begin(self, transaction_id, user=None):
""" Process a dict begin message
"""
self.transactions[transaction_id] = {}
self.transactions_user[transaction_id] = user.decode("utf8") if user else self.user
def process_set(self, transaction_id, key, value):
""" Process a dict set message
"""
# Nothing is actually set until everything is commited
self.transactions[transaction_id][key] = value
async def process_commit(self, transaction_id):
""" Process a dict commit message
"""
# Actually handle all set operations from the transaction store
results = []
for key, value in self.transactions[transaction_id].items():
logging.debug("Storing {}={}".format(key, value))
key_type, key = key.decode("utf8").split("/", 1)
result = await self.dict.set(
key, json.loads(value),
ns=(self.transactions_user[transaction_id] if key_type == "priv" else None)
)
# Remove stored transaction
del self.transactions[transaction_id]
del self.transactions_user[transaction_id]
return await self.reply(b"O", transaction_id)
async def reply(self, command, *args):
async with self.transport_lock:
logging.debug("Replying {} with {}".format(command, args))
self.transport.write(command)
self.transport.write(b"\t".join(map(tabescape, args)))
self.transport.write(b"\n")
@classmethod
def factory(cls, table_map):
""" Provide a protocol factory for a given map instance.
"""
return lambda: cls(table_map)
COMMANDS = {
ord("H"): process_hello,
ord("L"): process_lookup,
ord("I"): process_iterate,
ord("B"): process_begin,
ord("C"): process_commit,
ord("S"): process_set
}
def tabescape(unescaped):
""" Escape a string using the specific Dovecot tabescape
See: https://github.com/dovecot/core/blob/master/src/lib/strescape.c
"""
return unescaped.replace(b"\x01", b"\x011")\
.replace(b"\x00", b"\x010")\
.replace(b"\t", b"\x01t")\
.replace(b"\n", b"\x01n")\
.replace(b"\r", b"\x01r")
def tabunescape(escaped):
""" Unescape a string using the specific Dovecot tabescape
See: https://github.com/dovecot/core/blob/master/src/lib/strescape.c
"""
return escaped.replace(b"\x01r", b"\r")\
.replace(b"\x01n", b"\n")\
.replace(b"\x01t", b"\t")\
.replace(b"\x010", b"\x00")\
.replace(b"\x011", b"\x01")

@ -1,116 +0,0 @@
""" Postfix map proxy implementation
"""
import asyncio
import logging
class NetstringProtocol(asyncio.Protocol):
""" Netstring asyncio protocol implementation.
For protocol details, see https://cr.yp.to/proto/netstrings.txt
"""
# Length of the smallest allocated buffer, larger buffers will be
# allocated dynamically
BASE_BUFFER = 1024
# Maximum length of a buffer, will crash when exceeded
MAX_BUFFER = 65535
def __init__(self):
super(NetstringProtocol, self).__init__()
self.init_buffer()
def init_buffer(self):
self.len = None # None when waiting for a length to be sent)
self.separator = -1 # -1 when not yet detected (str.find)
self.index = 0 # relative to the buffer
self.buffer = bytearray(NetstringProtocol.BASE_BUFFER)
def data_received(self, data):
# Manage the buffer
missing = len(data) - len(self.buffer) + self.index
if missing > 0:
if len(self.buffer) + missing > NetstringProtocol.MAX_BUFFER:
raise IOError("Not enough space when decoding netstring")
self.buffer.append(bytearray(missing + 1))
new_index = self.index + len(data)
self.buffer[self.index:new_index] = data
self.index = new_index
# Try to detect a length at the beginning of the string
if self.len is None:
self.separator = self.buffer.find(0x3a)
if self.separator != -1 and self.buffer[:self.separator].isdigit():
self.len = int(self.buffer[:self.separator], 10)
# Then get the complete string
if self.len is not None:
if self.index - self.separator == self.len + 2:
string = self.buffer[self.separator + 1:self.index - 1]
self.init_buffer()
self.string_received(string)
def string_received(self, string):
""" A new netstring was received
"""
pass
def send_string(self, string):
""" Send a netstring
"""
logging.debug("Replying {}".format(string))
self.transport.write(str(len(string)).encode('ascii'))
self.transport.write(b':')
self.transport.write(string)
self.transport.write(b',')
class SocketmapProtocol(NetstringProtocol):
""" Protocol to answer Postfix socketmap and proxify lookups to
an outside object.
See http://www.postfix.org/socketmap_table.5.html for details on the
protocol.
A table map must be provided as a dictionary to lookup tables.
"""
def __init__(self, table_map):
self.table_map = table_map
super(SocketmapProtocol, self).__init__()
def connection_made(self, transport):
logging.info('Connect {}'.format(transport.get_extra_info('peername')))
self.transport = transport
def string_received(self, string):
# The postfix format contains a space for separating the map name and
# the key
logging.debug("Received {}".format(string))
space = string.find(0x20)
if space != -1:
name = string[:space].decode('ascii')
key = string[space+1:].decode('utf8')
return asyncio.ensure_future(self.process_request(name, key))
async def process_request(self, name, key):
""" Process a request by querying the provided map.
"""
logging.debug("Request {}/{}".format(name, key))
try:
table = self.table_map.get(name)
except KeyError:
return self.send_string(b'TEMP no such map')
try:
result = await table.get(key)
return self.send_string(b'OK ' + str(result).encode('utf8'))
except KeyError:
return self.send_string(b'NOTFOUND ')
except Exception:
logging.exception("Error when processing request")
return self.send_string(b'TEMP unknown error')
@classmethod
def factory(cls, table_map):
""" Provide a protocol factory for a given map instance.
"""
return lambda: cls(table_map)

@ -1,55 +0,0 @@
""" Table lookup backends for podop
"""
import aiohttp
import logging
from urllib.parse import quote
class UrlTable(object):
""" Resolve an entry by querying a parametrized GET URL.
"""
def __init__(self, url_pattern):
""" url_pattern must contain a format ``{}`` so the key is injected in
the url before the query, the ``§`` character will be replaced with
``{}`` for easier setup.
"""
self.url_pattern = url_pattern.replace('§', '{}')
async def get(self, key, ns=None):
""" Get the given key in the provided namespace
"""
logging.debug("Table get {}".format(key))
if ns is not None:
key += "/" + ns
async with aiohttp.ClientSession() as session:
quoted_key = quote(key)
async with session.get(self.url_pattern.format(quoted_key)) as request:
if request.status == 200:
result = await request.json()
logging.debug("Table get {} is {}".format(key, result))
return result
elif request.status == 404:
raise KeyError()
else:
raise Exception(request.status)
async def set(self, key, value, ns=None):
""" Set a value for the given key in the provided namespace
"""
logging.debug("Table set {} to {}".format(key, value))
if ns is not None:
key += "/" + ns
async with aiohttp.ClientSession() as session:
quoted_key = quote(key)
await session.post(self.url_pattern.format(quoted_key), json=value)
async def iter(self, cat):
""" Iterate the given key (experimental)
"""
logging.debug("Table iter {}".format(cat))
async with aiohttp.ClientSession() as session:
async with session.get(self.url_pattern.format(cat)) as request:
if request.status == 200:
result = await request.json()
return result

@ -1,33 +0,0 @@
#!/usr/bin/env python
import argparse
from podop import run_server, SERVER_TYPES, TABLE_TYPES
def main():
""" Run a podop server based on CLI arguments
"""
parser = argparse.ArgumentParser("Postfix and Dovecot proxy")
parser.add_argument("--socket", required=True,
help="path to the listening unix socket")
parser.add_argument("--mode", choices=SERVER_TYPES.keys(), required=True,
help="select which server will connect to Podop")
parser.add_argument("--name", action="append",
help="name of each configured table")
parser.add_argument("--type", choices=TABLE_TYPES.keys(), action="append",
help="type of each configured table")
parser.add_argument("--param", action="append",
help="mandatory param for each table configured")
parser.add_argument("-v", "--verbose", dest="verbosity",
action="count", default=0,
help="increases log verbosity for each occurence.")
args = parser.parse_args()
run_server(
args.verbosity, args.mode, args.socket,
zip(args.name, args.type, args.param) if args.name else []
)
if __name__ == "__main__":
main()

@ -1,23 +0,0 @@
#!/usr/bin/env python
from setuptools import setup
with open("README.md", "r") as fh:
long_description = fh.read()
setup(
name="podop",
version="0.2.5",
description="Postfix and Dovecot proxy",
long_description=long_description,
long_description_content_type="text/markdown",
author="Pierre Jaury",
author_email="pierre@jaury.eu",
url="https://github.com/mailu/podop.git",
packages=["podop"],
include_package_data=True,
scripts=["scripts/podop"],
install_requires=[
"aiohttp"
]
)

@ -1,22 +0,0 @@
.DS_Store
.idea
tmp
*.bak
*~
.*.swp
__pycache__/
*.pyc
*.pyo
*.egg-info/
.build
.env*
.venv
*.code-workspace
venv/
build/
dist/

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2019 Mailu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -1,2 +0,0 @@
include README.md
include LICENSE.md

@ -1,24 +0,0 @@
Socrate is a simple Python module providing a set of utility functions for
Python daemon applications.
The scope includes:
- configuration utilities (configuration parsing, etc.)
- system utilities (access to DNS, stats, etc.)
Setup
======
Socrate is available on Pypi, simpy run:
```
pip install socrate
```
Contributing
============
Podop is free software, open to suggestions and contributions. All
components are free software and compatible with the MIT license. All
the code is placed under the MIT license.

@ -1,24 +0,0 @@
#!/usr/bin/env python
import setuptools
from distutils.core import setup
with open("README.md", "r") as fh:
long_description = fh.read()
setup(
name="socrate",
version="0.2.0",
description="Socrate daemon utilities",
long_description=long_description,
long_description_content_type="text/markdown",
author="Pierre Jaury",
author_email="pierre@jaury.eu",
url="https://github.com/mailu/socrate.git",
packages=["socrate"],
include_package_data=True,
install_requires=[
"jinja2",
"tenacity"
]
)

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

Loading…
Cancel
Save