Compare commits

..

12 Commits

Author SHA1 Message Date
Alex V.
1d8e44a948 test: add error-path tests for all bug fixes
- test_doveauth: invalid localpart chars rejected, concurrent same-account creation
- test_expire: --mdir filtering uses msg.path correctly
- test_metadata: TURN exception returns N\n, success returns credentials
- test_turnserver: socket timeout, connection refused, happy path
- test_dns: get_dkim_entry returns (None, None) on CalledProcessError
- test_rshell: dovecot_recalc_quota handles empty/malformed output
2026-02-07 21:45:22 +03:00
Alex V.
447c0ee33d fix: handle turn_credentials exceptions in metadata proxy
ConnectionRefusedError/FileNotFoundError/TimeoutError from
turn_credentials() would kill the dict proxy connection.
Return N (not found) response instead and log the error.
2026-02-07 16:51:30 +03:00
Alex V.
47c9586b67 fix: add 5s timeout to TURN credential socket
Hung TURN daemon would block dict proxy handler thread indefinitely.
Per Python docs, settimeout() raises TimeoutError on expiry.
2026-02-07 16:51:19 +03:00
Alex V.
e32816b477 fix(security): validate localpart chars and fix account creation race
- Reject localparts with chars outside [a-z0-9._-] to prevent
  filesystem issues from crafted usernames via IMAP/SMTP auth
- Use filelock to serialize concurrent account creation for same
  address, preventing TOCTOU race where two threads both create
  an account and last writer wins
2026-02-07 16:51:01 +03:00
Alex V.
0d3cde9850 fix(security): remove deprecated TLS 1.0/1.1 from nginx config
TLS 1.0/1.1 deprecated by RFC 8996. Nginx default is TLSv1.2 TLSv1.3.
Aligns with postfix (>=TLSv1.2) and dovecot (TLSv1.3) in the same stack.
2026-02-07 16:50:43 +03:00
Alex V.
c48a7d80dc fix(security): use secrets.choice instead of random.choices for username
Per Python docs, secrets module should be used for security-sensitive
data. random.choices uses Mersenne Twister PRNG which is predictable.
secrets.choice was already used for password generation in the same file.
2026-02-07 16:50:24 +03:00
Alex V.
93eb996a42 fix: guard against IndexError in dovecot_recalc_quota
doveadm output ends with empty line, parts=[] causes parts[2] crash.
2026-02-07 16:30:04 +03:00
Alex V.
0148ecde8e fix: remove dead code and potential NameError in run_cmd
check_call always returns 0 or raises, making retcode!=0 branches
unreachable. Also remote_data was undefined with --skip-dns-check.
2026-02-07 16:29:38 +03:00
Alex V.
3ad8e5a6ee fix: handle build_webpages returning None in WebsiteDeployer
Exception in _build_webpages was silently caught, returning None.
rsync then received "None/" as source path, silently breaking deploy.
2026-02-07 16:29:14 +03:00
Alex V.
a5dc1d886d fix: return tuple from get_dkim_entry on CalledProcessError
Bare return yielded None, causing TypeError on tuple unpacking
in perform_initial_checks on fresh servers without DKIM keys.
2026-02-07 16:28:53 +03:00
Alex V.
0443965f63 fix: use msg.path instead of nonexistent msg.relpath in fsreport
FileEntry namedtuple has (path, mtime, size), not relpath.
Crashes with AttributeError when --mdir flag is used.
2026-02-07 16:28:42 +03:00
Alex V.
ea52fde2d9 chore: fix ruff formatting in acmetool, dovecot, postfix deployers 2026-02-07 16:27:46 +03:00
74 changed files with 455 additions and 2307 deletions

View File

@@ -1,18 +0,0 @@
data/
venv/
__pycache__
*.pyc
*.orig
*.ini
.pytest_cache
.env
# Slim build context — .git/ alone can be 100s of MB
.git
.github/
docs/
tests/
# Exclude markdown files but keep www/src/*.md (used by WebsiteDeployer)
*.md
!www/**/*.md

View File

@@ -15,7 +15,7 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.2.0/filtermail-x86_64-musl -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests
working-directory: chatmaild
run: pipx run tox

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
- j4n/docker-pr
pull_request:
paths-ignore:
- 'scripts/**'
@@ -12,67 +11,7 @@ on:
- 'CHANGELOG.md'
- 'LICENSE'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-docker:
name: Build Docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.image-ref.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Tagged releases: v1.2.3 -> :1.2.3, :1.2, :latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# Branch pushes: foo/docker-pr -> :foo-docker-pr
type=ref,event=branch
# Always: :sha-<hash>
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/chatmail_relay.dockerfile
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
GIT_HASH=${{ github.sha }}
- name: Output image reference
id: image-ref
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
deploy:
name: deploy on staging-ipv4.testrun.org, and run tests
runs-on: ubuntu-latest
@@ -116,7 +55,6 @@ jobs:
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
id: wait-for-vps
run: |
echo " --- wait until staging-ipv4.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
@@ -130,167 +68,29 @@ jobs:
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- name: setup dependencies
run: |
ssh root@staging-ipv4.testrun.org apt update
ssh root@staging-ipv4.testrun.org apt install -y git python3.11-venv python3-dev gcc
ssh root@staging-ipv4.testrun.org git clone https://github.com/chatmail/relay
ssh root@staging-ipv4.testrun.org "cd relay && git checkout " ${{ github.head_ref }}
ssh root@staging-ipv4.testrun.org "cd relay && scripts/initenv.sh"
- run: |
cmdeploy init staging-ipv4.testrun.org
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- name: initialize config
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy init staging-ipv4.testrun.org"
ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
- run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check --ssh-host localhost"
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone --ssh-host localhost"
ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow --ssh-host localhost"
- name: cmdeploy dns
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"
# --- Docker deploy (push only, runs even if bare failed) ---
- name: stop bare services
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging-ipv4.testrun.org 'systemctl stop postfix dovecot nginx opendkim unbound filtermail doveauth chatmail-metadata iroh-relay mtail fcgiwrap acmetool 2>/dev/null || true'
- name: install Docker on VPS
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging-ipv4.testrun.org 'apt-get update && apt-get install -y ca-certificates curl'
ssh root@staging-ipv4.testrun.org 'install -m 0755 -d /etc/apt/keyrings'
ssh root@staging-ipv4.testrun.org 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && chmod a+r /etc/apt/keyrings/docker.asc'
ssh root@staging-ipv4.testrun.org 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list'
ssh root@staging-ipv4.testrun.org 'apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin'
- name: prepare Docker bind mounts
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging-ipv4.testrun.org 'mkdir -p /srv/chatmail/certs /srv/chatmail/dkim'
ssh root@staging-ipv4.testrun.org 'cp -a /var/lib/acme/. /srv/chatmail/certs/ && cp -a /etc/dkimkeys/. /srv/chatmail/dkim/' || true
- name: upload chatmail.ini for Docker
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Reuse chatmail.ini already created by the bare-metal deploy steps
ssh root@staging-ipv4.testrun.org "cp relay/chatmail.ini /srv/chatmail/chatmail.ini"
- name: deploy with Docker
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
GHCR_IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}"
rsync -avz --exclude='.git' --exclude='venv' --exclude='__pycache__' ./ root@staging-ipv4.testrun.org:/srv/chatmail/relay/
# Login to GHCR on VPS and pull pre-built image
echo "${{ secrets.GITHUB_TOKEN }}" | ssh root@staging-ipv4.testrun.org 'docker login ghcr.io -u ${{ github.actor }} --password-stdin'
ssh root@staging-ipv4.testrun.org "docker pull ${GHCR_IMAGE}"
ssh root@staging-ipv4.testrun.org "cd /srv/chatmail/relay && CHATMAIL_IMAGE=${GHCR_IMAGE} MAIL_DOMAIN=staging-ipv4.testrun.org docker compose -f docker/docker-compose.yaml -f docker/docker-compose.ci.yaml up -d"
- name: wait for container healthy
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Stream journald inside the container
ssh root@staging-ipv4.testrun.org 'docker exec chatmail journalctl -f --no-pager' &
LOG_PID=$!
trap "kill $LOG_PID 2>/dev/null || true" EXIT
for i in $(seq 1 60); do
status=$(ssh root@staging-ipv4.testrun.org 'docker inspect --format={{.State.Health.Status}} chatmail 2>/dev/null' || echo "missing")
echo " [$i/60] status=$status"
if [ "$status" = "healthy" ]; then
echo "Container is healthy."
exit 0
fi
if [ "$status" = "unhealthy" ]; then
echo "Container is unhealthy!"
break
fi
sleep 5
done
echo "Container did not become healthy."
kill $LOG_PID 2>/dev/null || true
echo "--- failed units ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail systemctl --failed --no-pager' || true
echo "--- service logs ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail journalctl -u dovecot -u postfix -u nginx -u unbound --no-pager -n 50' || true
echo "--- listening ports ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail ss -tlnp' || true
echo "--- chatmail.ini ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini' || true
exit 1
- name: show container state
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
echo "--- listening ports ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail ss -tlnp'
echo "--- chatmail.ini ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini'
- name: Docker integration tests
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging-ipv4.testrun.org 'docker exec chatmail cmdeploy test --slow --ssh-host @local'
- name: Docker DNS
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Reset zone file in case bare DNS already appended to it
git checkout .github/workflows/staging-ipv4.testrun.org-default.zone
ssh root@staging-ipv4.testrun.org 'docker exec chatmail chown opendkim:opendkim -R /etc/dkimkeys'
ssh root@staging-ipv4.testrun.org 'docker exec chatmail cmdeploy dns --ssh-host @local --zonefile /opt/chatmail/staging.zone --verbose'
ssh root@staging-ipv4.testrun.org 'docker cp chatmail:/opt/chatmail/staging.zone /tmp/staging.zone'
scp root@staging-ipv4.testrun.org:/tmp/staging.zone staging-generated.zone
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: Docker final DNS check
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: ssh root@staging-ipv4.testrun.org 'docker exec chatmail cmdeploy dns -v --ssh-host @local'
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
# --- Cleanup ---
- name: cmdeploy dns
run: cmdeploy dns -v
- name: add SSH keys
if: >-
!cancelled()
&& steps.wait-for-vps.outcome == 'success'
run: ssh root@staging-ipv4.testrun.org 'curl -s https://github.com/hpk42.keys https://github.com/j4n.keys >> .ssh/authorized_keys'

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
- j4n/docker-pr
pull_request:
paths-ignore:
- 'scripts/**'
@@ -12,67 +11,7 @@ on:
- 'CHANGELOG.md'
- 'LICENSE'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-docker:
name: Build Docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.image-ref.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Tagged releases: v1.2.3 -> :1.2.3, :1.2, :latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# Branch pushes: foo/docker-pr -> :foo-docker-pr
type=ref,event=branch
# Always: :sha-<hash>
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/chatmail_relay.dockerfile
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
GIT_HASH=${{ github.sha }}
- name: Output image reference
id: image-ref
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
deploy:
name: deploy on staging2.testrun.org, and run tests
runs-on: ubuntu-latest
@@ -116,7 +55,6 @@ jobs:
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
id: wait-for-vps
run: |
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
@@ -133,8 +71,8 @@ jobs:
- name: add hpk42 key to staging server
run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys >> .ssh/authorized_keys'
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- run: |
cmdeploy init staging2.testrun.org
@@ -144,6 +82,7 @@ jobs:
- name: set DNS entries
run: |
ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone --verbose
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
@@ -157,133 +96,3 @@ jobs:
- name: cmdeploy dns
run: cmdeploy dns -v
# --- Docker deploy (push only, runs even if bare failed) ---
- name: stop bare services
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging2.testrun.org 'systemctl stop postfix dovecot nginx opendkim unbound filtermail doveauth chatmail-metadata iroh-relay mtail fcgiwrap acmetool 2>/dev/null || true'
- name: install Docker on VPS
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging2.testrun.org 'apt-get update && apt-get install -y ca-certificates curl'
ssh root@staging2.testrun.org 'install -m 0755 -d /etc/apt/keyrings'
ssh root@staging2.testrun.org 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && chmod a+r /etc/apt/keyrings/docker.asc'
ssh root@staging2.testrun.org 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list'
ssh root@staging2.testrun.org 'apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin'
- name: prepare Docker bind mounts
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging2.testrun.org 'mkdir -p /srv/chatmail/certs /srv/chatmail/dkim'
ssh root@staging2.testrun.org 'cp -a /var/lib/acme/. /srv/chatmail/certs/ && cp -a /etc/dkimkeys/. /srv/chatmail/dkim/' || true
- name: upload chatmail.ini for Docker
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Reuse chatmail.ini already created by the bare-metal deploy steps
scp chatmail.ini root@staging2.testrun.org:/srv/chatmail/chatmail.ini
- name: deploy with Docker
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
GHCR_IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}"
rsync -avz --exclude='.git' --exclude='venv' --exclude='__pycache__' ./ root@staging2.testrun.org:/srv/chatmail/relay/
# Login to GHCR on VPS and pull pre-built image
echo "${{ secrets.GITHUB_TOKEN }}" | ssh root@staging2.testrun.org 'docker login ghcr.io -u ${{ github.actor }} --password-stdin'
ssh root@staging2.testrun.org "docker pull ${GHCR_IMAGE}"
ssh root@staging2.testrun.org "cd /srv/chatmail/relay && CHATMAIL_IMAGE=${GHCR_IMAGE} MAIL_DOMAIN=staging2.testrun.org docker compose -f docker/docker-compose.yaml -f docker/docker-compose.ci.yaml up -d"
- name: wait for container healthy
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Stream journald inside the container
ssh root@staging2.testrun.org 'docker exec chatmail journalctl -f --no-pager' &
LOG_PID=$!
trap "kill $LOG_PID 2>/dev/null || true" EXIT
for i in $(seq 1 60); do
status=$(ssh root@staging2.testrun.org 'docker inspect --format={{.State.Health.Status}} chatmail 2>/dev/null' || echo "missing")
echo " [$i/60] status=$status"
if [ "$status" = "healthy" ]; then
echo "Container is healthy."
exit 0
fi
if [ "$status" = "unhealthy" ]; then
echo "Container is unhealthy!"
break
fi
sleep 5
done
echo "Container did not become healthy."
kill $LOG_PID 2>/dev/null || true
echo "--- failed units ---"
ssh root@staging2.testrun.org 'docker exec chatmail systemctl --failed --no-pager' || true
echo "--- service logs ---"
ssh root@staging2.testrun.org 'docker exec chatmail journalctl -u dovecot -u postfix -u nginx -u unbound --no-pager -n 50' || true
echo "--- listening ports ---"
ssh root@staging2.testrun.org 'docker exec chatmail ss -tlnp' || true
echo "--- chatmail.ini ---"
ssh root@staging2.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini' || true
exit 1
- name: show container state
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
echo "--- listening ports ---"
ssh root@staging2.testrun.org 'docker exec chatmail ss -tlnp'
echo "--- chatmail.ini ---"
ssh root@staging2.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini'
- name: Docker integration tests
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging2.testrun.org 'docker exec chatmail cmdeploy test --slow --ssh-host @local'
- name: Docker DNS
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Reset zone file in case bare DNS already appended to it
git checkout .github/workflows/staging.testrun.org-default.zone
ssh root@staging2.testrun.org 'docker exec chatmail chown opendkim:opendkim -R /etc/dkimkeys'
ssh root@staging2.testrun.org 'docker exec chatmail cmdeploy dns --ssh-host @local --zonefile /opt/chatmail/staging.zone --verbose'
ssh root@staging2.testrun.org 'docker cp chatmail:/opt/chatmail/staging.zone /tmp/staging.zone'
scp root@staging2.testrun.org:/tmp/staging.zone staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: Docker final DNS check
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: ssh root@staging2.testrun.org 'docker exec chatmail cmdeploy dns -v --ssh-host @local'
# --- Cleanup ---
- name: add SSH keys
if: >-
!cancelled()
&& steps.wait-for-vps.outcome == 'success'
run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys https://github.com/j4n.keys >> .ssh/authorized_keys'

8
.gitignore vendored
View File

@@ -4,7 +4,7 @@ __pycache__/
*$py.class
*.swp
*qr-*.png
chatmail*.ini
chatmail.ini
# C extensions
@@ -164,9 +164,3 @@ cython_debug/
#.idea/
chatmail.zone
# docker
/data/
/custom/
docker/docker-compose.override.yaml
docker/.env

View File

@@ -24,6 +24,7 @@ where = ['src']
[project.scripts]
doveauth = "chatmaild.doveauth:main"
chatmail-metadata = "chatmaild.metadata:main"
chatmail-metrics = "chatmaild.metrics:main"
chatmail-expire = "chatmaild.expire:main"
chatmail-fsreport = "chatmaild.fsreport:main"
lastlogin = "chatmaild.lastlogin:main"

View File

@@ -1,4 +1,3 @@
import os
from pathlib import Path
import iniconfig
@@ -44,8 +43,6 @@ class Config:
)
self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
self.acme_email = params.get("acme_email", "")
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.get("imap_compress", "false").lower() == "true"
@@ -60,31 +57,6 @@ class Config:
self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.get("privacy_supervisor")
# TLS certificate management.
# If tls_external_cert_and_key is set, use externally managed certs.
# Otherwise derived from the domain name:
# - Domains starting with "_" use self-signed certificates
# - All other domains use ACME.
external = params.get("tls_external_cert_and_key", "").strip()
if external:
parts = external.split()
if len(parts) != 2:
raise ValueError(
"tls_external_cert_and_key must have two space-separated"
" paths: CERT_PATH KEY_PATH"
)
self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts
elif self.mail_domain.startswith("_"):
self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key"
else:
self.tls_cert_mode = "acme"
self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain"
self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey"
# deprecated option
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
self.mailboxes_dir = Path(mbdir.strip())

View File

@@ -13,20 +13,9 @@ to show storage summaries only for first 1000 mailboxes
python -m chatmaild.fsreport /path/to/chatmail.ini --maxnum 1000
to write Prometheus textfile for node_exporter
python -m chatmaild.fsreport --textfile /var/lib/prometheus/node-exporter/
writes to /var/lib/prometheus/node-exporter/fsreport.prom
to also write legacy metrics.py style output (default: /var/www/html/metrics):
python -m chatmaild.fsreport --textfile /var/lib/prometheus/node-exporter/ --legacy-metrics
"""
import os
import tempfile
from argparse import ArgumentParser
from datetime import datetime
@@ -59,19 +48,7 @@ class Report:
self.num_ci_logins = self.num_all_logins = 0
self.login_buckets = {x: 0 for x in (1, 10, 30, 40, 80, 100, 150)}
KiB = 1024
MiB = 1024 * KiB
self.message_size_thresholds = (
0,
100 * KiB,
MiB // 2,
1 * MiB,
2 * MiB,
5 * MiB,
10 * MiB,
)
self.message_buckets = {x: 0 for x in self.message_size_thresholds}
self.message_count_buckets = {x: 0 for x in self.message_size_thresholds}
self.message_buckets = {x: 0 for x in (0, 160000, 500000, 2000000)}
def process_mailbox_stat(self, mailbox):
# categorize login times
@@ -94,7 +71,6 @@ class Report:
if self.mdir and f"/{self.mdir}/" not in msg.path:
continue
self.message_buckets[size] += msg.size
self.message_count_buckets[size] += 1
self.size_messages += sum(entry.size for entry in mailbox.messages)
self.size_extra += sum(entry.size for entry in mailbox.extrafiles)
@@ -117,10 +93,9 @@ class Report:
pref = f"[{self.mdir}] " if self.mdir else ""
for minsize, sumsize in self.message_buckets.items():
count = self.message_count_buckets[minsize]
percent = (sumsize / all_messages * 100) if all_messages else 0
print(
f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%), {count} msgs"
f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%)"
)
user_logins = self.num_all_logins - self.num_ci_logins
@@ -136,75 +111,6 @@ class Report:
for days, active in self.login_buckets.items():
print(f"last {days:3} days: {HSize(active)} {p(active)}")
def _write_atomic(self, filepath, content):
"""Atomically write content to filepath via tmp+rename."""
dirpath = os.path.dirname(os.path.abspath(filepath))
fd, tmppath = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
f.write(content)
os.chmod(tmppath, 0o644)
os.rename(tmppath, filepath)
except BaseException:
try:
os.unlink(tmppath)
except OSError:
pass
raise
def dump_textfile(self, filepath):
"""Dump metrics in Prometheus exposition format."""
lines = []
lines.append("# HELP chatmail_storage_bytes Mailbox storage in bytes.")
lines.append("# TYPE chatmail_storage_bytes gauge")
lines.append(f'chatmail_storage_bytes{{kind="messages"}} {self.size_messages}')
lines.append(f'chatmail_storage_bytes{{kind="extra"}} {self.size_extra}')
total = self.size_extra + self.size_messages
lines.append(f'chatmail_storage_bytes{{kind="total"}} {total}')
lines.append("# HELP chatmail_messages_bytes Sum of msg bytes >= threshold.")
lines.append("# TYPE chatmail_messages_bytes gauge")
for minsize, sumsize in self.message_buckets.items():
lines.append(f'chatmail_messages_bytes{{min_size="{minsize}"}} {sumsize}')
lines.append("# HELP chatmail_messages_count Number of msgs >= size threshold.")
lines.append("# TYPE chatmail_messages_count gauge")
for minsize, count in self.message_count_buckets.items():
lines.append(f'chatmail_messages_count{{min_size="{minsize}"}} {count}')
lines.append("# HELP chatmail_accounts Number of accounts.")
lines.append("# TYPE chatmail_accounts gauge")
user_logins = self.num_all_logins - self.num_ci_logins
lines.append(f'chatmail_accounts{{kind="all"}} {self.num_all_logins}')
lines.append(f'chatmail_accounts{{kind="ci"}} {self.num_ci_logins}')
lines.append(f'chatmail_accounts{{kind="user"}} {user_logins}')
lines.append(
"# HELP chatmail_accounts_active Non-CI accounts active within N days."
)
lines.append("# TYPE chatmail_accounts_active gauge")
for days, active in self.login_buckets.items():
lines.append(f'chatmail_accounts_active{{days="{days}"}} {active}')
self._write_atomic(filepath, "\n".join(lines) + "\n")
def dump_compat_textfile(self, filepath):
"""Dump legacy metrics.py style metrics."""
user_logins = self.num_all_logins - self.num_ci_logins
lines = [
"# HELP total number of accounts",
"# TYPE accounts gauge",
f"accounts {self.num_all_logins}",
"# HELP number of CI accounts",
"# TYPE ci_accounts gauge",
f"ci_accounts {self.num_ci_logins}",
"# HELP number of non-CI accounts",
"# TYPE nonci_accounts gauge",
f"nonci_accounts {user_logins}",
]
self._write_atomic(filepath, "\n".join(lines) + "\n")
def main(args=None):
"""Report about filesystem storage usage of all mailboxes and messages"""
@@ -221,21 +127,19 @@ def main(args=None):
"--days",
default=0,
action="store",
help="assume date to be DAYS older than now",
help="assume date to be days older than now",
)
parser.add_argument(
"--min-login-age",
default=0,
metavar="DAYS",
dest="min_login_age",
action="store",
help="only sum up message size if last login is at least DAYS days old",
help="only sum up message size if last login is at least min-login-age days old",
)
parser.add_argument(
"--mdir",
metavar="{cur,new,tmp}",
action="store",
help="only consider messages in specified Maildir subdirectory for summary",
help="only consider 'cur' or 'new' or 'tmp' messages for summary",
)
parser.add_argument(
@@ -244,21 +148,6 @@ def main(args=None):
action="store",
help="maximum number of mailboxes to iterate on",
)
parser.add_argument(
"--textfile",
metavar="PATH",
default=None,
help="write Prometheus textfile to PATH (directory or file); "
"if PATH is a directory, writes 'fsreport.prom' inside it",
)
parser.add_argument(
"--legacy-metrics",
metavar="FILENAME",
nargs="?",
const="/var/www/html/metrics",
default=None,
help="write legacy metrics.py textfile (default: /var/www/html/metrics)",
)
args = parser.parse_args(args)
@@ -272,15 +161,7 @@ def main(args=None):
rep = Report(now=now, min_login_age=int(args.min_login_age), mdir=args.mdir)
for mbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
rep.process_mailbox_stat(mbox)
if args.textfile:
path = args.textfile
if os.path.isdir(path):
path = os.path.join(path, "fsreport.prom")
rep.dump_textfile(path)
if args.legacy_metrics:
rep.dump_compat_textfile(args.legacy_metrics)
if not args.textfile and not args.legacy_metrics:
rep.dump_summary()
rep.dump_summary()
if __name__ == "__main__":

View File

@@ -48,13 +48,6 @@ passthrough_senders =
# (space-separated, item may start with "@" to whitelist whole recipient domains)
passthrough_recipients =
# Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine).
# Both files must already exist before running cmdeploy.
# Certificate renewal is your responsibility; changed files are
# picked up automatically by all relay services.
# tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
#www_folder = www

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
def main(vmail_dir=None):
if vmail_dir is None:
vmail_dir = sys.argv[1]
accounts = 0
ci_accounts = 0
for path in Path(vmail_dir).iterdir():
if not path.joinpath("cur").is_dir():
continue
accounts += 1
if path.name[:3] in ("ci-", "ac_"):
ci_accounts += 1
print("# HELP total number of accounts")
print("# TYPE accounts gauge")
print(f"accounts {accounts}")
print("# HELP number of CI accounts")
print("# TYPE ci_accounts gauge")
print(f"ci_accounts {ci_accounts}")
print("# HELP number of non-CI accounts")
print("# TYPE nonci_accounts gauge")
print(f"nonci_accounts {accounts - ci_accounts}")
if __name__ == "__main__":
main()

View File

@@ -5,7 +5,6 @@
import json
import secrets
import string
from urllib.parse import quote
from chatmaild.config import Config, read_config
@@ -25,26 +24,13 @@ def create_newemail_dict(config: Config):
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
def create_dclogin_url(email, password):
"""Build a dclogin: URL with credentials and self-signed cert acceptance.
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
can connect to servers with self-signed TLS certificates.
"""
return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3"
def print_new_account():
config = read_config(CONFIG_PATH)
creds = create_newemail_dict(config)
result = dict(email=creds["email"], password=creds["password"])
if config.tls_cert_mode == "self":
result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"])
print("Content-Type: application/json")
print("")
print(json.dumps(result))
print(json.dumps(creds))
if __name__ == "__main__":

View File

@@ -73,51 +73,3 @@ def test_config_userstate_paths(make_config, tmp_path):
def test_config_max_message_size(make_config, tmp_path):
config = make_config("something.testrun.org", dict(max_message_size="10000"))
assert config.max_message_size == 10000
def test_config_tls_default_acme(make_config):
config = make_config("chat.example.org")
assert config.tls_cert_mode == "acme"
assert config.tls_cert_path == "/var/lib/acme/live/chat.example.org/fullchain"
assert config.tls_key_path == "/var/lib/acme/live/chat.example.org/privkey"
def test_config_tls_self(make_config):
config = make_config("_test.example.org")
assert config.tls_cert_mode == "self"
assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem"
assert config.tls_key_path == "/etc/ssl/private/mailserver.key"
def test_config_tls_external(make_config):
config = make_config(
"chat.example.org",
{
"tls_external_cert_and_key": "/custom/fullchain.pem /custom/privkey.pem",
},
)
assert config.tls_cert_mode == "external"
assert config.tls_cert_path == "/custom/fullchain.pem"
assert config.tls_key_path == "/custom/privkey.pem"
def test_config_tls_external_overrides_underscore(make_config):
config = make_config(
"_test.example.org",
{
"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem",
},
)
assert config.tls_cert_mode == "external"
assert config.tls_cert_path == "/certs/fullchain.pem"
assert config.tls_key_path == "/certs/privkey.pem"
def test_config_tls_external_bad_format(make_config):
with pytest.raises(ValueError, match="two space-separated"):
make_config(
"chat.example.org",
{
"tls_external_cert_and_key": "/only/one/path.pem",
},
)

View File

@@ -1,15 +1,9 @@
import shutil
import smtplib
import subprocess
import sys
import pytest
pytestmark = pytest.mark.skipif(
shutil.which("filtermail") is None,
reason="filtermail binary not found",
)
@pytest.fixture
def smtpserver():
@@ -47,8 +41,6 @@ def test_one_mail(
make_config, make_popen, smtpserver, maildata, filtermail_mode, monkeypatch
):
monkeypatch.setenv("PYTHONUNBUFFERED", "1")
# DKIM is tested by cmdeploy tests.
monkeypatch.setenv("FILTERMAIL_SKIP_DKIM", "1")
smtp_inject_port = 20025
if filtermail_mode == "outgoing":
settings = dict(
@@ -66,10 +58,6 @@ def test_one_mail(
popen = make_popen(["filtermail", path, filtermail_mode])
line = popen.stderr.readline().strip()
# skip a warning that FILTERMAIL_SKIP_DKIM shouldn't be used in prod
if b"DKIM verification DISABLED!" in line:
line = popen.stderr.readline().strip()
if b"loop" not in line:
print(line.decode("ascii"), file=sys.stderr)
pytest.fail("starting filtermail failed")

View File

@@ -0,0 +1,24 @@
from chatmaild.metrics import main
def test_main(tmp_path, capsys):
paths = []
for x in ("ci-asllkj", "ac_12l3kj", "qweqwe", "ci-l1k2j31l2k3"):
p = tmp_path.joinpath(x)
p.mkdir()
p.joinpath("cur").mkdir()
paths.append(p)
tmp_path.joinpath("nomailbox").mkdir()
main(tmp_path)
out, _ = capsys.readouterr()
d = {}
for line in out.split("\n"):
if line.strip() and not line.startswith("#"):
name, num = line.split()
d[name] = int(num)
assert d["accounts"] == 4
assert d["ci_accounts"] == 3
assert d["nonci_accounts"] == 1

View File

@@ -1,11 +1,7 @@
import json
import chatmaild
from chatmaild.newemail import (
create_dclogin_url,
create_newemail_dict,
print_new_account,
)
from chatmaild.newemail import create_newemail_dict, print_new_account
def test_create_newemail_dict(example_config):
@@ -19,18 +15,6 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"]
def test_create_dclogin_url():
url = create_dclogin_url("user@example.org", "p@ss w+rd")
assert url.startswith("dclogin:")
assert "v=1" in url
assert "ic=3" in url
assert "user@example.org" in url
# password special chars must be encoded
assert "p%40ss" in url
assert "w%2Brd" in url
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account()
@@ -41,20 +25,3 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf
dic = json.loads(lines[2])
assert dic["email"].endswith(f"@{example_config.mail_domain}")
assert len(dic["password"]) >= 10
# default tls_cert=acme should not include dclogin_url
assert "dclogin_url" not in dic
def test_print_new_account_self_signed(capsys, monkeypatch, make_config):
config = make_config("_test.example.org")
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(config._inipath))
print_new_account()
out, err = capsys.readouterr()
lines = out.split("\n")
dic = json.loads(lines[2])
assert "dclogin_url" in dic
url = dic["dclogin_url"]
assert url.startswith("dclogin:")
assert "ic=3" in url
assert dic["email"].split("@")[0] in url

View File

@@ -20,7 +20,6 @@ dependencies = [
"pytest-xdist",
"execnet",
"imap_tools",
"deltachat-rpc-client",
]
[project.scripts]

View File

@@ -3,7 +3,7 @@ Description=acmetool HTTP redirector
[Service]
Type=notify
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon --bind=127.0.0.1:402
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon
Restart=always
RestartSec=30

View File

@@ -1,38 +1,10 @@
import importlib.resources
import io
import os
from contextlib import contextmanager
from pyinfra.operations import files, server, systemd
def has_systemd():
"""Returns False during Docker image builds or any other non-systemd environment."""
return os.path.isdir("/run/systemd/system")
@contextmanager
def blocked_service_startup():
"""Prevent services from auto-starting during package installation.
Installs a ``/usr/sbin/policy-rc.d`` that exits 101, blocking any
service from being started by the package manager. This avoids bind
conflicts and CPU/RAM spikes during initial setup. The file is removed
when the context exits.
"""
# For documentation about policy-rc.d, see:
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
files.put(
src=get_resource("policy-rc.d"),
dest="/usr/sbin/policy-rc.d",
user="root",
group="root",
mode="755",
)
yield
files.file("/usr/sbin/policy-rc.d", present=False)
def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg)

View File

@@ -8,10 +8,8 @@
{{ mail_domain }}. AAAA {{ AAAA }}
{% endif %}
{{ mail_domain }}. MX 10 {{ mail_domain }}.
{% if strict_tls %}
_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}"
mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}.
{% endif %}
www.{{ mail_domain }}. CNAME {{ mail_domain }}.
{{ dkim_entry }}

View File

@@ -5,6 +5,7 @@ along with command line option and subcommand parsing.
import argparse
import importlib.resources
import importlib.util
import os
import pathlib
import shutil
@@ -90,10 +91,9 @@ def run_cmd(args, out):
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay
strict_tls = args.config.tls_cert_mode == "acme"
if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
if not dns.check_initial_remote_data(remote_data, print=out.red):
return 1
env = os.environ.copy()
@@ -101,14 +101,11 @@ def run_cmd(args, out):
env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else ""
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
if not args.dns_check_disabled:
env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or ""
env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or ""
deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve()
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host == "localhost":
if ssh_host in ["localhost", "@docker"]:
cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -119,9 +116,6 @@ def run_cmd(args, out):
out.check_call(cmd, env=env)
if args.website_only:
out.green("Website deployment completed.")
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again")
else:
out.green("Deploy completed, call `cmdeploy dns` next.")
return 0
@@ -145,13 +139,11 @@ def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
tls_cert_mode = args.config.tls_cert_mode
strict_tls = tls_cert_mode == "acme"
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls):
if not remote_data:
return 1
if strict_tls and not remote_data["acme_account_url"]:
if not remote_data["acme_account_url"]:
out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
return 1
@@ -159,7 +151,6 @@ def dns_cmd(args, out):
out.red("could not determine dkim_entry, please run 'cmdeploy run'")
return 1
remote_data["strict_tls"] = strict_tls
zonefile = dns.get_filled_zone_file(remote_data)
if args.zonefile:
@@ -200,15 +191,17 @@ def test_cmd_options(parser):
action="store_true",
help="also run slow tests",
)
add_ssh_host_option(parser)
def test_cmd(args, out):
"""Run local and online tests for chatmail deployment."""
"""Run local and online tests for chatmail deployment.
env = os.environ.copy()
if args.ssh_host:
env["CHATMAIL_SSH"] = args.ssh_host
This will automatically pip-install 'deltachat' if it's not available.
"""
x = importlib.util.find_spec("deltachat")
if x is None:
out.check_call(f"{sys.executable} -m pip install deltachat")
pytest_path = shutil.which("pytest")
pytest_args = [
@@ -222,7 +215,7 @@ def test_cmd(args, out):
]
if args.slow:
pytest_args.append("--slow")
ret = out.run_ret(pytest_args, env=env)
ret = out.run_ret(pytest_args)
return ret
@@ -313,7 +306,7 @@ def add_ssh_host_option(parser):
parser.add_argument(
"--ssh-host",
dest="ssh_host",
help="Run commands on 'localhost' or on a specific SSH host "
help="Run commands on 'localhost', via '@docker', or on a specific SSH host "
"instead of chatmail.ini's mail_domain.",
)
@@ -323,7 +316,7 @@ def add_config_option(parser):
"--config",
dest="inipath",
action="store",
default=Path(os.environ.get("CHATMAIL_INI", "chatmail.ini")),
default=Path("chatmail.ini"),
type=Path,
help="path to the chatmail.ini file",
)
@@ -375,7 +368,9 @@ def get_parser():
def get_sshexec(ssh_host: str, verbose=True):
if ssh_host in ["localhost", "@local"]:
return LocalExec(verbose)
return LocalExec(verbose, docker=False)
elif ssh_host == "@docker":
return LocalExec(verbose, docker=True)
if verbose:
print(f"[ssh] login to {ssh_host}")
return SSHExec(ssh_host, verbose=verbose)

View File

@@ -5,15 +5,13 @@ Chat Mail pyinfra deploy.
import shutil
import subprocess
import sys
from io import BytesIO, StringIO
from io import StringIO
from pathlib import Path
from chatmaild.config import read_config
from pyinfra import facts, host, logger
from pyinfra.api import FactBase
from pyinfra.facts import hardware
from pyinfra.facts.files import Sha256File
from pyinfra.facts.server import Command
from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd
@@ -26,22 +24,19 @@ from .basedeploy import (
activate_remote_units,
configure_remote_units,
get_resource,
has_systemd,
)
from .dovecot.deployer import DovecotDeployer
from .external.deployer import ExternalTlsDeployer
from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer
from .opendkim.deployer import OpendkimDeployer
from .postfix.deployer import PostfixDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
from .www import build_webpages, find_merge_conflict, get_paths
class Port(FactBase):
"""
Returns the process occupying a port.
Returns the process occuping a port.
"""
def command(self, port: int) -> str:
@@ -69,8 +64,6 @@ def _build_chatmaild(dist_dir) -> None:
def remove_legacy_artifacts():
if not has_systemd():
return
# disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service(
@@ -123,6 +116,7 @@ def _install_remote_venv_with_chatmaild() -> None:
def _configure_remote_venv_with_chatmaild(config) -> None:
remote_base_dir = "/usr/local/lib/chatmaild"
remote_venv_dir = f"{remote_base_dir}/venv"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644")
@@ -133,13 +127,16 @@ def _configure_remote_venv_with_chatmaild(config) -> None:
**root_owned,
)
files.file(
path="/etc/cron.d/chatmail-metrics",
present=False,
)
files.file(
path="/var/www/html/metrics",
present=False,
files.template(
src=get_resource("metrics.cron.j2"),
dest="/etc/cron.d/chatmail-metrics",
user="root",
group="root",
mode="644",
config={
"mailboxes_dir": config.mailboxes_dir,
"execpath": f"{remote_venv_dir}/bin/chatmail-metrics",
},
)
@@ -304,7 +301,7 @@ class LegacyRemoveDeployer(Deployer):
present=False,
)
# remove echobot if it is still running
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
if host.get_fact(SystemdEnabled).get("echobot.service"):
systemd.service(
name="Disable echobot.service",
service="echobot.service",
@@ -478,14 +475,6 @@ class ChatmailDeployer(Deployer):
self.mail_domain = mail_domain
def install(self):
files.put(
name="Disable installing recommended packages globally",
src=BytesIO(b'APT::Install-Recommends "false";\n'),
dest="/etc/apt/apt.conf.d/00InstallRecommends",
user="root",
group="root",
mode="644",
)
apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True)
@@ -548,20 +537,6 @@ class GithashDeployer(Deployer):
)
def get_tls_deployer(config, mail_domain):
"""Select the appropriate TLS deployer based on config."""
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
if config.tls_cert_mode == "acme":
return AcmetoolDeployer(config.acme_email, tls_domains)
elif config.tls_cert_mode == "self":
return SelfSignedTlsDeployer(mail_domain)
elif config.tls_cert_mode == "external":
return ExternalTlsDeployer(config.tls_cert_path, config.tls_key_path)
else:
raise ValueError(f"Unknown tls_cert_mode: {config.tls_cert_mode}")
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
"""Deploy a chat-mail instance.
@@ -585,52 +560,35 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
line="\nnameserver 9.9.9.9",
)
# Check if mtail_address interface is available (if configured)
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
if config.mtail_address not in all_addresses:
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
exit(1)
port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
("acmetool", 80),
(["imap-login", "dovecot"], 143),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("mtail", 3903),
("dovecot-stats", 3904),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
if running_service:
if running_service not in service:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
if host.get_fact(Command, "systemd-detect-virt -c || true") == "none":
port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
]
if config.tls_cert_mode == "acme":
port_services.append(("acmetool", 402))
port_services += [
(["imap-login", "dovecot"], 143),
# acmetool previously listened on port 80,
# so don't complain during upgrade that moved it to port 402
# and gave the port to nginx.
(["acmetool", "nginx"], 80),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("mtail", 3903),
("stats", 3904),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
services = [service] if isinstance(service, str) else service
if running_service:
if running_service not in services:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
tls_deployer = get_tls_deployer(config, mail_domain)
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
all_deployers = [
ChatmailDeployer(mail_domain),
@@ -640,7 +598,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
UnboundDeployer(config),
TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay),
tls_deployer,
AcmetoolDeployer(config.acme_email, tls_domains),
WebsiteDeployer(config),
ChatmailVenvDeployer(config),
MtastsDeployer(),

View File

@@ -12,14 +12,14 @@ def get_initial_remote_data(sshexec, mail_domain):
)
def check_initial_remote_data(remote_data, *, strict_tls=True, print=print):
def check_initial_remote_data(remote_data, *, print=print):
mail_domain = remote_data["mail_domain"]
if not remote_data["A"] and not remote_data["AAAA"]:
print(f"Missing A and/or AAAA DNS records for {mail_domain}!")
elif strict_tls and remote_data["MTA_STS"] != f"{mail_domain}.":
elif remote_data["MTA_STS"] != f"{mail_domain}.":
print("Missing MTA-STS CNAME record:")
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.")
elif strict_tls and remote_data["WWW"] != f"{mail_domain}.":
elif remote_data["WWW"] != f"{mail_domain}.":
print("Missing www CNAME record:")
print(f"www.{mail_domain}. CNAME {mail_domain}.")
else:

View File

@@ -1,30 +1,16 @@
import urllib.request
from chatmaild.config import Config
from pyinfra import host
from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Arch, Command, Sysctl
from pyinfra.facts.server import Arch, Sysctl
from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import (
Deployer,
activate_remote_units,
blocked_service_startup,
configure_remote_units,
get_resource,
)
DOVECOT_VERSION = "2.3.21+dfsg1-3"
DOVECOT_SHA256 = {
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
}
class DovecotDeployer(Deployer):
daemon_reload = False
@@ -36,7 +22,7 @@ class DovecotDeployer(Deployer):
def install(self):
arch = host.get_fact(Arch)
with blocked_service_startup():
if not "dovecot.service" in host.get_fact(SystemdEnabled):
_install_dovecot_package("core", arch)
_install_dovecot_package("imapd", arch)
_install_dovecot_package("lmtpd", arch)
@@ -63,45 +49,38 @@ class DovecotDeployer(Deployer):
self.need_restart = False
def _pick_url(primary, fallback):
try:
req = urllib.request.Request(primary, method="HEAD")
urllib.request.urlopen(req, timeout=10)
return primary
except Exception:
return fallback
def _install_dovecot_package(package: str, arch: str):
arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch
url = f"https://download.delta.chat/dovecot/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
deb_filename = "/root/" + url.split("/")[-1]
pkg_name = f"dovecot-{package}"
sha256 = DOVECOT_SHA256.get((package, arch))
if sha256 is None:
apt.packages(packages=[pkg_name])
return
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if DOVECOT_VERSION in installed_versions:
return
url_version = DOVECOT_VERSION.replace("+", "%2B")
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
url = _pick_url(primary_url, fallback_url)
deb_filename = f"/root/{deb_base}"
match (package, arch):
case ("core", "amd64"):
sha256 = "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d"
case ("core", "arm64"):
sha256 = "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9"
case ("imapd", "amd64"):
sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86"
case ("imapd", "arm64"):
sha256 = "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f"
case ("lmtpd", "amd64"):
sha256 = "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab"
case ("lmtpd", "arm64"):
sha256 = "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f"
case _:
apt.packages(packages=[f"dovecot-{package}"])
return
files.download(
name=f"Download {pkg_name}",
name=f"Download dovecot-{package}",
src=url,
dest=deb_filename,
sha256sum=sha256,
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
)
apt.deb(name=f"Install {pkg_name}", src=deb_filename)
apt.deb(name=f"Install dovecot-{package}", src=deb_filename)
def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
@@ -139,18 +118,11 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits
can_modify = host.get_fact(Command, "systemd-detect-virt -c || true") == "none"
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
value = host.get_fact(Sysctl)[key]
if value > 65534:
continue
if not can_modify:
print(
"\n!!!! refusing to attempt sysctl setting in shared-kernel containers\n"
f"!!!! dovecot: sysctl {key!r}={value}, should be >65534 for production setups\n"
"!!!!"
)
if host.get_fact(Sysctl)[key] > 65535:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl(
name=f"Change {key}",

View File

@@ -228,8 +228,8 @@ service anvil {
}
ssl = required
ssl_cert = <{{ config.tls_cert_path }}
ssl_key = <{{ config.tls_key_path }}
ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain
ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey
ssl_dh = </usr/share/dovecot/dh.pem
ssl_min_protocol = TLSv1.3
ssl_prefer_server_ciphers = yes

View File

@@ -1,67 +0,0 @@
import io
from pyinfra import host
from pyinfra.facts.files import File
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer, get_resource
class ExternalTlsDeployer(Deployer):
"""Expects TLS certificates to be managed on the server.
Validates that the configured certificate and key files
exist on the remote host. Installs a systemd path unit
that watches the certificate file and automatically
restarts/reloads affected services when it changes.
"""
def __init__(self, cert_path, key_path):
self.cert_path = cert_path
self.key_path = key_path
def configure(self):
# Verify cert and key exist on the remote host using pyinfra facts.
for path in (self.cert_path, self.key_path):
info = host.get_fact(File, path=path)
if info is None:
raise Exception(f"External TLS file not found on server: {path}")
# Deploy the .path unit (templated with the cert path).
# pkg=__package__ is required here because the resource files
# live in cmdeploy.external, not the default cmdeploy package.
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
content = source.read_text().format(cert_path=self.cert_path).encode()
path_unit = files.put(
name="Upload tls-cert-reload.path",
src=io.BytesIO(content),
dest="/etc/systemd/system/tls-cert-reload.path",
user="root",
group="root",
mode="644",
)
service_unit = files.put(
name="Upload tls-cert-reload.service",
src=get_resource("tls-cert-reload.service", pkg=__package__),
dest="/etc/systemd/system/tls-cert-reload.service",
user="root",
group="root",
mode="644",
)
if path_unit.changed or service_unit.changed:
self.need_restart = True
def activate(self):
systemd.service(
name="Enable tls-cert-reload path watcher",
service="tls-cert-reload.path",
running=True,
enabled=True,
restarted=self.need_restart,
daemon_reload=self.need_restart,
)
# No explicit reload needed here: dovecot/nginx read the cert
# on startup, and the .path watcher handles live changes.

View File

@@ -1,15 +0,0 @@
# Watch the TLS certificate file for changes.
# When the cert is updated (e.g. renewed by an external process),
# this triggers tls-cert-reload.service to reload the affected services.
#
# NOTE: changes to the certificates are not detected if they cross bind-mount boundaries.
# After cert renewal, you must then trigger the reload explicitly:
# systemctl start tls-cert-reload.service
[Unit]
Description=Watch TLS certificate for changes
[Path]
PathChanged={cert_path}
[Install]
WantedBy=multi-user.target

View File

@@ -1,15 +0,0 @@
# Reload services that cache the TLS certificate.
#
# dovecot: caches the cert at startup; reload re-reads SSL certs
# without dropping existing connections.
# nginx: caches the cert at startup; reload gracefully picks up
# the new cert for new connections.
# postfix: reads the cert fresh on each TLS handshake,
# does NOT need a reload/restart.
[Unit]
Description=Reload TLS services after certificate change
[Service]
Type=oneshot
ExecStart=/bin/systemctl try-reload-or-restart dovecot
ExecStart=/bin/systemctl try-reload-or-restart nginx

View File

@@ -14,10 +14,10 @@ class FiltermailDeployer(Deployer):
def install(self):
arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.0/filtermail-{arch}"
url = f"https://github.com/chatmail/filtermail/releases/download/v0.2.0/filtermail-{arch}-musl"
sha256sum = {
"x86_64": "3fd8b18282252c75a5bbfa603d8c1b65f6563e5e920bddf3e64e451b7cdb43ce",
"aarch64": "2bd191de205f7fd60158dd8e3516ab7e3efb14627696f3d7dc186bdcd9e10a43",
"x86_64": "1e5bbb646582cb16740c6dfbbca39edba492b78cc96ec9fa2528c612bb504edd",
"aarch64": "3564fba8605f8f9adfeefff3f4580533205da043f47c5968d0d10db17e50f44e",
}[arch]
self.need_restart |= files.download(
name="Download filtermail",

View File

@@ -0,0 +1 @@
*/5 * * * * root {{ config.execpath }} {{ config.mailboxes_dir }} >/var/www/html/metrics

View File

@@ -1,47 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="{{ config.mail_domain }}">
<domain>{{ config.mail_domain }}</domain>
<displayName>{{ config.mail_domain }} chatmail</displayName>
<displayShortName>{{ config.mail_domain }}</displayShortName>
<emailProvider id="{{ config.domain_name }}">
<domain>{{ config.domain_name }}</domain>
<displayName>{{ config.domain_name }} chatmail</displayName>
<displayShortName>{{ config.domain_name }}</displayShortName>
<incomingServer type="imap">
<hostname>{{ config.mail_domain }}</hostname>
<hostname>{{ config.domain_name }}</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.mail_domain }}</hostname>
<hostname>{{ config.domain_name }}</hostname>
<port>143</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.mail_domain }}</hostname>
<hostname>{{ config.domain_name }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>{{ config.mail_domain }}</hostname>
<hostname>{{ config.domain_name }}</hostname>
<port>465</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.mail_domain }}</hostname>
<hostname>{{ config.domain_name }}</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.mail_domain }}</hostname>
<hostname>{{ config.domain_name }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>

View File

@@ -70,7 +70,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root",
group="root",
mode="644",
config=config,
config={"domain_name": config.mail_domain},
disable_ipv6=config.disable_ipv6,
)
need_restart |= main_config.changed
@@ -81,7 +81,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root",
group="root",
mode="644",
config=config,
config={"domain_name": config.mail_domain},
)
need_restart |= autoconfig.changed
@@ -91,7 +91,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root",
group="root",
mode="644",
config=config,
config={"domain_name": config.mail_domain},
)
need_restart |= mta_sts_config.changed

View File

@@ -1,4 +1,4 @@
version: STSv1
mode: enforce
mx: {{ config.mail_domain }}
mx: {{ config.domain_name }}
max_age: 2419200

View File

@@ -42,9 +42,6 @@ stream {
}
http {
{% if config.tls_cert_mode == "self" %}
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
{% endif %}
sendfile on;
tcp_nopush on;
@@ -56,8 +53,8 @@ http {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_certificate {{ config.tls_cert_path }};
ssl_certificate_key {{ config.tls_key_path }};
ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain;
ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey;
gzip on;
@@ -69,7 +66,7 @@ http {
index index.html index.htm;
server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
server_name {{ config.domain_name }} www.{{ config.domain_name }} mta-sts.{{ config.domain_name }};
access_log syslog:server=unix:/dev/log,facility=local7;
@@ -79,16 +76,16 @@ http {
try_files $uri $uri/ =404;
}
location /metrics {
default_type text/plain;
}
location /new {
{% if config.tls_cert_mode != "self" %}
if ($request_method = GET) {
# Redirect to Delta Chat,
# which will in turn do a POST request.
return 301 dcaccount:https://{{ config.mail_domain }}/new;
return 301 dcaccount:https://{{ config.domain_name }}/new;
}
{% else %}
limit_req zone=newaccount burst=5 nodelay;
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
@@ -102,11 +99,9 @@ http {
#
# Redirects are only for browsers.
location /cgi-bin/newemail.py {
{% if config.tls_cert_mode != "self" %}
if ($request_method = GET) {
return 301 dcaccount:https://{{ config.mail_domain }}/new;
return 301 dcaccount:https://{{ config.domain_name }}/new;
}
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
@@ -137,29 +132,8 @@ http {
# Redirect www. to non-www
server {
listen 127.0.0.1:8443 ssl;
server_name www.{{ config.mail_domain }};
return 301 $scheme://{{ config.mail_domain }}$request_uri;
server_name www.{{ config.domain_name }};
return 301 $scheme://{{ config.domain_name }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;
}
server {
listen 80;
{% if not disable_ipv6 %}
listen [::]:80;
{% endif %}
{% if config.tls_cert_mode == "acme" %}
location /.well-known/acme-challenge/ {
proxy_pass http://acmetool;
}
{% endif %}
return 301 https://$host$request_uri;
}
{% if config.tls_cert_mode == "acme" %}
upstream acmetool {
server 127.0.0.1:402;
}
{% endif %}
}

View File

@@ -37,15 +37,21 @@ class OpendkimDeployer(Deployer):
)
need_restart |= main_config.changed
screen_script = files.file(
path="/etc/opendkim/screen.lua",
present=False,
screen_script = files.put(
src=get_resource("opendkim/screen.lua"),
dest="/etc/opendkim/screen.lua",
user="root",
group="root",
mode="644",
)
need_restart |= screen_script.changed
final_script = files.file(
path="/etc/opendkim/final.lua",
present=False,
final_script = files.put(
src=get_resource("opendkim/final.lua"),
dest="/etc/opendkim/final.lua",
user="root",
group="root",
mode="644",
)
need_restart |= final_script.changed
@@ -103,13 +109,6 @@ class OpendkimDeployer(Deployer):
)
need_restart |= service_file.changed
files.file(
name="chown opendkim: /etc/dkimkeys/opendkim.private",
path="/etc/dkimkeys/opendkim.private",
user="opendkim",
group="opendkim",
)
self.need_restart = need_restart
def activate(self):

View File

@@ -0,0 +1,42 @@
mtaname = odkim.get_mtasymbol(ctx, "{daemon_name}")
if mtaname == "ORIGINATING" then
-- Outgoing message will be signed,
-- no need to look for signatures.
return nil
end
nsigs = odkim.get_sigcount(ctx)
if nsigs == nil then
return nil
end
local valid = false
local error_msg = "No valid DKIM signature found."
for i = 1, nsigs do
sig = odkim.get_sighandle(ctx, i - 1)
sigres = odkim.sig_result(sig)
-- All signatures that do not correspond to From:
-- were ignored in screen.lua and return sigres -1.
--
-- Any valid signature that was not ignored like this
-- means the message is acceptable.
if sigres == 0 then
valid = true
else
error_msg = "DKIM signature is invalid, error code " .. tostring(sigres) .. ", search https://github.com/trusteddomainproject/OpenDKIM/blob/master/libopendkim/dkim.h#L108"
end
end
if valid then
-- Strip all DKIM-Signature headers after successful validation
-- Delete in reverse order to avoid index shifting.
for i = nsigs, 1, -1 do
odkim.del_header(ctx, "DKIM-Signature", i)
end
else
odkim.set_reply(ctx, "554", "5.7.1", error_msg)
odkim.set_result(ctx, SMFIS_REJECT)
end
return nil

View File

@@ -45,6 +45,12 @@ SignHeaders *,+autocrypt,+content-type
# Default is empty.
OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt
# Script to ignore signatures that do not correspond to the From: domain.
ScreenPolicyScript /etc/opendkim/screen.lua
# Script to reject mails without a valid DKIM signature.
FinalPolicyScript /etc/opendkim/final.lua
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged
# user (for example, Postfix). You may need to add user "postfix" to group

View File

@@ -0,0 +1,21 @@
-- Ignore signatures that do not correspond to the From: domain.
from_domain = odkim.get_fromdomain(ctx)
if from_domain == nil then
return nil
end
n = odkim.get_sigcount(ctx)
if n == nil then
return nil
end
for i = 1, n do
sig = odkim.get_sighandle(ctx, i - 1)
sig_domain = odkim.sig_getdomain(sig)
if from_domain ~= sig_domain then
odkim.sig_ignore(sig)
end
end
return nil

View File

@@ -61,20 +61,6 @@ class PostfixDeployer(Deployer):
)
need_restart |= lmtp_header_cleanup.changed
tls_policy_map = files.put(
name="Upload SMTP TLS Policy that accepts self-signed certificates for IP-only hosts",
src=get_resource("postfix/smtp_tls_policy_map"),
dest="/etc/postfix/smtp_tls_policy_map",
user="root",
group="root",
mode="644",
)
need_restart |= tls_policy_map.changed
if tls_policy_map.changed:
server.shell(
commands=["postmap /etc/postfix/smtp_tls_policy_map"],
)
# Login map that 1:1 maps email address to login.
login_map = files.put(
src=get_resource("postfix/login_map"),

View File

@@ -1,3 +1,2 @@
/^DKIM-Signature:/ IGNORE
/^Authentication-Results:/ IGNORE
/^Received:/ IGNORE

View File

@@ -15,17 +15,17 @@ readme_directory = no
compatibility_level = 3.6
# TLS parameters
smtpd_tls_cert_file={{ config.tls_cert_path }}
smtpd_tls_key_file={{ config.tls_key_path }}
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain
smtpd_tls_key_file=/var/lib/acme/live/{{ config.mail_domain }}/privkey
smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level={{ "verify" if config.tls_cert_mode == "acme" else "encrypt" }}
smtp_tls_security_level=verify
# Send SNI extension when connecting to other servers.
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = regexp:/etc/postfix/smtp_tls_policy_map
smtp_tls_policy_maps = inline:{nauta.cu=may}
smtp_tls_protocols = >=TLSv1.2
smtp_tls_mandatory_protocols = >=TLSv1.2
@@ -69,15 +69,6 @@ mynetworks = 127.0.0.0/8
{% else %}
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
{% endif %}
{% if config.addr_v4 %}
smtp_bind_address = {{ config.addr_v4 }}
{% endif %}
{% if config.addr_v6 %}
smtp_bind_address6 = {{ config.addr_v6 }}
{% endif %}
{% if config.addr_v4 or config.addr_v6 %}
smtp_bind_address_enforce = yes
{% endif %}
mailbox_size_limit = 0
message_size_limit = {{config.max_message_size}}
recipient_delimiter = +

View File

@@ -86,6 +86,7 @@ filter unix - n n - - lmtp
# Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject_incoming
-o smtpd_milters=unix:opendkim/opendkim.sock
# Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP.

View File

@@ -1,3 +0,0 @@
/^\[[^]]+\]$/ encrypt
/^_/ encrypt
/^nauta\.cu$/ may

View File

@@ -1,52 +0,0 @@
import shlex
from pyinfra.operations import apt, server
from cmdeploy.basedeploy import Deployer
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
"""Return the openssl argument list for a self-signed certificate.
The certificate uses an EC P-256 key with SAN entries for *domain*,
``www.<domain>`` and ``mta-sts.<domain>``.
"""
return [
"openssl", "req", "-x509",
"-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256",
"-noenc", "-days", str(days),
"-keyout", str(key_path),
"-out", str(cert_path),
"-subj", f"/CN={domain}",
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext",
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
]
class SelfSignedTlsDeployer(Deployer):
"""Generates a self-signed TLS certificate for all chatmail endpoints."""
def __init__(self, mail_domain):
self.mail_domain = mail_domain
self.cert_path = "/etc/ssl/certs/mailserver.pem"
self.key_path = "/etc/ssl/private/mailserver.key"
def install(self):
apt.packages(
name="Install openssl",
packages=["openssl"],
)
def configure(self):
args = openssl_selfsigned_args(
self.mail_domain, self.cert_path, self.key_path,
)
cmd = shlex.join(args)
server.shell(
name="Generate self-signed TLS certificate if not present",
commands=[f"[ -f {self.cert_path} ] || {cmd}"],
)
def activate(self):
pass

View File

@@ -5,5 +5,5 @@ After=network.target
[Service]
Type=oneshot
User=vmail
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini

View File

@@ -4,7 +4,7 @@ Description=Chatmail dict proxy for IMAP METADATA
[Service]
ExecStart={execpath} /run/chatmail-metadata/metadata.socket {config_path}
Restart=always
RestartSec=5
RestartSec=30
User=vmail
RuntimeDirectory=chatmail-metadata
UMask=0077

View File

@@ -85,26 +85,16 @@ class SSHExec:
class LocalExec:
FuncError = FuncError
def __init__(self, verbose=False):
def __init__(self, verbose=False, docker=False):
self.verbose = verbose
def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None:
kwargs = {}
return call(**kwargs)
self.docker = docker
def logged(self, call, kwargs: dict):
title = call.__doc__
if not title:
title = call.__name__
where = "locally"
if self.docker:
if call == remote.rdns.perform_initial_checks:
kwargs["pre_command"] = "docker exec chatmail "
where = "in docker"
if self.verbose:
print_stderr(f"Running {where}: {title}(**{kwargs})")
return self(call, kwargs, log_callback=print_stderr)
else:
print_stderr(title, end="")
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
print_stderr()
return res
print(f"Running {where}: {call.__name__}(**{kwargs})")
return call(**kwargs)

View File

@@ -41,9 +41,9 @@ class TestDC:
def dc_ping_pong():
chat.send_text("ping")
msg = ac2.wait_for_incoming_msg()
msg.get_snapshot().chat.send_text("pong")
ac1.wait_for_incoming_msg()
msg = ac2._evtracker.wait_next_incoming_message()
msg.chat.send_text("pong")
ac1._evtracker.wait_next_incoming_message()
benchmark(dc_ping_pong, 5)
@@ -55,6 +55,6 @@ class TestDC:
for i in range(10):
chat.send_text(f"hello {i}")
for i in range(10):
ac2.wait_for_incoming_msg()
ac2._evtracker.wait_next_incoming_message()
benchmark(dc_send_10_receive_10, 5, cooldown="auto")
benchmark(dc_send_10_receive_10, 5)

View File

@@ -89,9 +89,7 @@ def test_concurrent_logins_same_account(
assert login_results.get()
def test_no_vrfy(cmfactory, chatmail_config):
ac = cmfactory.get_online_account()
addr = ac.get_config("addr")
def test_no_vrfy(chatmail_config):
domain = chatmail_config.mail_domain
s = smtplib.SMTP(domain)
@@ -100,7 +98,7 @@ def test_no_vrfy(cmfactory, chatmail_config):
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
result = s.getreply()
print(result)
s.putcmd("vrfy", addr)
s.putcmd("vrfy", f"echo@{chatmail_config.mail_domain}")
result2 = s.getreply()
print(result2)
assert result[0] == result2[0] == 252

View File

@@ -1,4 +1,3 @@
import pytest
import requests
from cmdeploy.genqr import gen_qr_png_data
@@ -9,33 +8,18 @@ def test_gen_qr_png_data(maildomain):
assert data
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_fastcgi_working(maildomain, chatmail_config):
url = f"https://{maildomain}/new"
print(url)
verify = chatmail_config.tls_cert_mode == "acme"
res = requests.post(url, verify=verify)
res = requests.post(url)
assert maildomain in res.json().get("email")
assert len(res.json().get("password")) > chatmail_config.password_min_length
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_newemail_configure(maildomain, rpc, chatmail_config):
def test_newemail_configure(maildomain, rpc):
"""Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new"
for i in range(3):
account_id = rpc.add_account()
if chatmail_config.tls_cert_mode == "self":
# deltachat core's rustls rejects self-signed HTTPS certs during
# set_config_from_qr, so fetch credentials via requests instead
res = requests.post(f"https://{maildomain}/new", verify=False)
data = res.json()
rpc.add_or_update_transport(account_id, {
"addr": data["email"],
"password": data["password"],
"imapServer": maildomain,
"smtpServer": maildomain,
"certificateChecks": "acceptInvalidCertificates",
})
else:
rpc.add_transport_from_qr(account_id, url)
rpc.set_config_from_qr(account_id, url)
rpc.configure(account_id)

View File

@@ -7,13 +7,13 @@ import time
import pytest
from cmdeploy import remote
from cmdeploy.cmdeploy import get_sshexec
from cmdeploy.sshexec import SSHExec
class TestSSHExecutor:
@pytest.fixture(scope="class")
def sshexec(self, sshdomain):
return get_sshexec(sshdomain)
return SSHExec(sshdomain)
def test_ls(self, sshexec):
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
@@ -27,7 +27,6 @@ class TestSSHExecutor:
assert res["A"] or res["AAAA"]
def test_logged(self, sshexec, maildomain, capsys):
sshexec.verbose = False
sshexec.logged(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
)
@@ -53,8 +52,6 @@ class TestSSHExecutor:
remote.rdns.perform_initial_checks,
kwargs=dict(mail_domain=None),
)
except AssertionError:
pass
except sshexec.FuncError as e:
assert "rdns.py" in str(e)
assert "AssertionError" in str(e)
@@ -86,8 +83,10 @@ def test_remote(remote, imap_or_smtp):
def test_use_two_chatmailservers(cmfactory, maildomain2):
ac1 = cmfactory.get_online_account()
ac2 = cmfactory.get_online_account(domain=maildomain2)
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
cmfactory.get_accepted_chat(ac1, ac2)
domain1 = ac1.get_config("addr").split("@")[1]
domain2 = ac2.get_config("addr").split("@")[1]
@@ -147,7 +146,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
conn.starttls()
with conn as s:
with pytest.raises(smtplib.SMTPDataError, match="No DKIM signature found"):
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
@@ -219,7 +218,7 @@ def test_expunged(remote, chatmail_config):
]
outdated_days = int(chatmail_config.delete_large_after) + 1
find_cmds.append(
f"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
)
for cmd in find_cmds:
for line in remote.iter_output(cmd):

View File

@@ -6,17 +6,16 @@ import imap_tools
import pytest
import requests
from cmdeploy.cmdeploy import get_sshexec
from cmdeploy.remote import rshell
from cmdeploy.sshexec import SSHExec
@pytest.fixture
def imap_mailbox(cmfactory, ssl_context):
def imap_mailbox(cmfactory):
(ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr")
password = ac1.get_config("mail_pw")
host = user.split("@")[1]
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox = imap_tools.MailBox(user.split("@")[1])
mailbox.login(user, password)
mailbox.dc_ac = ac1
return mailbox
@@ -27,7 +26,6 @@ class TestMetadataTokens:
def test_set_get_metadata(self, imap_mailbox):
"set and get metadata token for an account"
time.sleep(5) # make sure Metadata service had a chance to restart
client = imap_mailbox.client
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "1111" )\n')
res = client.readline()
@@ -63,8 +61,8 @@ class TestEndToEndDeltaChat:
chat.send_text("message0")
lp.sec("wait for ac2 to receive message")
msg2 = ac2.wait_for_incoming_msg()
assert msg2.get_snapshot().text == "message0"
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0"
def test_exceed_quota(
self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain
@@ -92,41 +90,45 @@ class TestEndToEndDeltaChat:
lp.sec(f"filling remote inbox for {user}")
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2,"
path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn)
sshexec = get_sshexec(sshdomain)
sshexec = SSHExec(sshdomain)
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
assert res["percent"] >= 100
lp.sec("ac2: check quota is triggered")
def send_hello():
chat.send_text("hello")
for line in remote.iter_output(
"journalctl -n1 -f -u dovecot", ready=send_hello
):
starting = True
for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
if starting:
chat.send_text("hello")
starting = False
if user not in line:
# print(line)
continue
if "quota exceeded" in line:
return
def test_securejoin(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.get_online_account()
ac2 = cmfactory.get_online_account(domain=maildomain2)
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin")
qr = ac1.get_qr_code()
qr = ac1.get_setup_contact_qr()
lp.sec("ac2: start QR-code based setup contact protocol")
ch = ac2.secure_join(qr)
ch = ac2.qr_setup_contact(qr)
assert ch.id >= 10
ac1.wait_for_securejoin_inviter_success()
ac1._evtracker.wait_securejoin_inviter_progress(1000)
def test_dkim_header_stripped(self, cmfactory, maildomain2, lp, imap_mailbox):
"""Test that if a DC address receives a message, it has no
DKIM-Signature and Authentication-Results headers."""
ac1 = cmfactory.get_online_account()
ac2 = cmfactory.get_online_account(domain=maildomain2)
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac)
chat.send_text("message0")
chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac)
@@ -143,32 +145,33 @@ class TestEndToEndDeltaChat:
assert "dkim-signature" not in msg.headers
def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.get_online_account()
ac2 = cmfactory.get_online_account(domain=maildomain2)
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
qr = ac1.get_qr_code()
ch = ac2.secure_join(qr)
qr = ac1.get_setup_contact_qr()
ch = ac2.qr_setup_contact(qr)
assert ch.id >= 10
ac1.wait_for_securejoin_inviter_success()
ac1._evtracker.wait_securejoin_inviter_progress(1000)
lp.sec("ac1 sends a message and ac2 marks it as seen")
chat = ac1.create_chat(ac2)
msg = chat.send_text("hi")
m = ac2.wait_for_incoming_msg()
m = ac2._evtracker.wait_next_incoming_message()
m.mark_seen()
# we can only indirectly wait for mark-seen to cause an smtp-error
lp.sec("try to wait for markseen to complete and check error states")
deadline = time.time() + 3.1
while time.time() < deadline:
m_snap = m.get_snapshot()
msgs = m_snap.chat.get_messages()
msgs = m.chat.get_messages()
for msg in msgs:
assert "error" not in m.get_info()
assert "error" not in m.get_message_info()
time.sleep(1)
def test_hide_senders_ip_address(cmfactory, ssl_context):
def test_hide_senders_ip_address(cmfactory):
public_ip = requests.get("http://icanhazip.com").content.decode().strip()
assert ipaddress.ip_address(public_ip)
@@ -176,12 +179,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat = cmfactory.get_accepted_chat(user1, user2)
chat.send_text("testing submission header cleanup")
user2.wait_for_incoming_msg()
addr = user2.get_config("addr")
host = addr.split("@")[1]
pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw)
msgs = list(mailbox.fetch(mark_seen=False))
assert msgs, "expected at least one message"
assert public_ip not in msgs[0].obj.as_string()
user2._evtracker.wait_next_incoming_message()
user2.direct_imap.select_folder("Inbox")
msg = user2.direct_imap.get_all_messages()[0]
assert public_ip not in msg.obj.as_string()

View File

@@ -5,11 +5,7 @@ from cmdeploy.cmdeploy import main
def test_status_cmd(chatmail_config, capsys, request):
os.chdir(request.config.invocation_params.dir)
command = ["status"]
if os.getenv("CHATMAIL_SSH"):
command.append("--ssh-host")
command.append(os.getenv("CHATMAIL_SSH"))
assert main(command) == 0
assert main(["status"]) == 0
status_out = capsys.readouterr()
print(status_out.out)

View File

@@ -1,9 +1,9 @@
import imaplib
import io
import itertools
import os
import random
import smtplib
import ssl
import subprocess
import time
from pathlib import Path
@@ -34,24 +34,17 @@ def pytest_runtest_setup(item):
pytest.skip("skipping slow test, use --slow to run")
def _get_chatmail_config():
current = Path().resolve()
@pytest.fixture(scope="session")
def chatmail_config(pytestconfig):
current = basedir = Path().resolve()
while 1:
path = current.joinpath("chatmail.ini").resolve()
if path.exists():
return read_config(path), path
return read_config(path)
if current == current.parent:
break
current = current.parent
return None, None
@pytest.fixture(scope="session")
def chatmail_config(pytestconfig):
config, path = _get_chatmail_config()
if config:
return config
basedir = Path().resolve()
pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
@@ -79,17 +72,10 @@ def sshdomain2(maildomain2):
def pytest_report_header():
config, path = _get_chatmail_config()
domain2 = os.environ.get("CHATMAIL_DOMAIN2", "NOT SET")
domain = config.mail_domain if config else "NOT SET"
path = path if path else "NOT SET"
lines = [
f"chatmail.ini {domain} location: {path}",
f"chatmail2: {domain2}",
]
sep = "-" * max(map(len, lines))
return [sep, *lines, sep]
domain = os.environ.get("CHATMAIL_DOMAIN")
if domain:
text = f"chatmail test instance: {domain}"
return ["-" * len(text), text, "-" * len(text)]
@pytest.fixture
@@ -104,22 +90,15 @@ def cm_data(request):
@pytest.fixture
def benchmark(request, chatmail_config):
def bench(func, num, name=None, reportfunc=None, cooldown=0.0):
def benchmark(request):
def bench(func, num, name=None, reportfunc=None):
if name is None:
name = func.__name__
if cooldown == "auto":
per_minute = max(chatmail_config.max_user_send_per_minute, 1)
cooldown = chatmail_config.max_user_send_burst_size * 60 / per_minute
durations = []
for i in range(num):
now = time.time()
func()
durations.append(time.time() - now)
if cooldown > 0 and i + 1 < num:
# Keep post-run cooldown out of measured benchmark duration.
time.sleep(cooldown)
durations.sort()
request.config._benchresults[name] = (reportfunc, durations)
@@ -165,25 +144,15 @@ def pytest_terminal_summary(terminalreporter):
tr.write_line(line)
@pytest.fixture(scope="session")
def ssl_context(chatmail_config):
if chatmail_config.tls_cert_mode == "self":
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
return None
@pytest.fixture
def imap(maildomain):
return ImapConn(maildomain)
@pytest.fixture
def imap(maildomain, ssl_context):
return ImapConn(maildomain, ssl_context=ssl_context)
@pytest.fixture
def make_imap_connection(maildomain, ssl_context):
def make_imap_connection(maildomain):
def make_imap_connection():
conn = ImapConn(maildomain, ssl_context=ssl_context)
conn = ImapConn(maildomain)
conn.connect()
return conn
@@ -195,13 +164,12 @@ class ImapConn:
logcmd = "journalctl -f -u dovecot"
name = "dovecot"
def __init__(self, host, ssl_context=None):
def __init__(self, host):
self.host = host
self.ssl_context = ssl_context
def connect(self):
print(f"imap-connect {self.host}")
self.conn = imaplib.IMAP4_SSL(self.host, ssl_context=self.ssl_context)
self.conn = imaplib.IMAP4_SSL(self.host)
def login(self, user, password):
print(f"imap-login {user!r} {password!r}")
@@ -227,14 +195,14 @@ class ImapConn:
@pytest.fixture
def smtp(maildomain, ssl_context):
return SmtpConn(maildomain, ssl_context=ssl_context)
def smtp(maildomain):
return SmtpConn(maildomain)
@pytest.fixture
def make_smtp_connection(maildomain, ssl_context):
def make_smtp_connection(maildomain):
def make_smtp_connection():
conn = SmtpConn(maildomain, ssl_context=ssl_context)
conn = SmtpConn(maildomain)
conn.connect()
return conn
@@ -246,14 +214,12 @@ class SmtpConn:
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
name = "postfix"
def __init__(self, host, ssl_context=None):
def __init__(self, host):
self.host = host
self.ssl_context = ssl_context
def connect(self):
print(f"smtp-connect {self.host}")
context = self.ssl_context or ssl.create_default_context()
self.conn = smtplib.SMTP_SSL(self.host, context=context)
self.conn = smtplib.SMTP_SSL(self.host)
def login(self, user, password):
print(f"smtp-login {user!r} {password!r}")
@@ -296,94 +262,68 @@ def gencreds(chatmail_config):
#
# Delta Chat RPC-based test support
# Delta Chat testplugin re-use
# use the cmfactory fixture to get chatmail instance accounts
#
from deltachat_rpc_client import DeltaChat, Rpc
class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
class ChatmailACFactory:
"""RPC-based account factory for chatmail testing."""
def __init__(self, rpc, maildomain, gencreds, chatmail_config):
self.dc = DeltaChat(rpc)
self.rpc = rpc
self._maildomain = maildomain
def __init__(self, pytestconfig, maildomain, gencreds):
self.pytestconfig = pytestconfig
self.maildomain = maildomain
assert "." in self.maildomain, maildomain
self.gencreds = gencreds
self.chatmail_config = chatmail_config
self._addr2files = {}
def _make_transport(self, domain):
"""Build a transport config dict for the given domain."""
addr, password = self.gencreds(domain)
transport = {
"addr": addr,
"password": password,
# Setting server explicitly skips requesting autoconfig XML,
# see https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/
"imapServer": domain,
"smtpServer": domain,
}
if self.chatmail_config.tls_cert_mode == "self":
transport["certificateChecks"] = "acceptInvalidCertificates"
return transport
def get_liveconfig_producer(self):
while 1:
user, password = self.gencreds(self.maildomain)
config = {
"addr": user,
"mail_pw": password,
}
# speed up account configuration
config["mail_server"] = self.maildomain
config["send_server"] = self.maildomain
yield config
def get_online_account(self, domain=None):
"""Create, configure and bring online a single account."""
return self.get_online_accounts(1, domain)[0]
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
pass
def get_online_accounts(self, num, domain=None):
"""Create multiple online accounts in parallel."""
domain = domain or self._maildomain
futures = []
accounts = []
for _ in range(num):
account = self.dc.add_account()
future = account.add_or_update_transport.future(
self._make_transport(domain)
)
futures.append(future)
# ensure messages stay in INBOX so that they can be
# concurrently fetched via extra IMAP connections during tests
account.set_config("delete_server_after", "10")
accounts.append(account)
for future in futures:
future()
for account in accounts:
account.bring_online()
return accounts
def get_accepted_chat(self, ac1, ac2):
"""Create a 1:1 chat between ac1 and ac2 accepted on both sides."""
ac2.create_chat(ac1)
return ac1.create_chat(ac2)
@pytest.fixture(scope="session")
def rpc(tmp_path_factory):
"""Start a deltachat-rpc-server process for the test session."""
# NB: accounts_dir must NOT already exist as directory --
# core-rust only creates accounts.toml if the dir doesn't exist yet.
accounts_dir = str(tmp_path_factory.mktemp("dc") / "accounts")
rpc = Rpc(accounts_dir=accounts_dir)
rpc.start()
yield rpc
rpc.close()
def cache_maybe_store_configured_db_files(self, acc):
pass
@pytest.fixture
def cmfactory(rpc, gencreds, maildomain, chatmail_config):
"""Return a ChatmailACFactory for creating online Delta Chat accounts."""
return ChatmailACFactory(
rpc=rpc,
maildomain=maildomain,
gencreds=gencreds,
chatmail_config=chatmail_config,
)
def cmfactory(request, gencreds, tmpdir, maildomain):
# cloned from deltachat.testplugin.amfactory
pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory
testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
class Data:
def read_path(self, path):
return
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data())
# nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support
def switch_maildomain(maildomain2):
am.testprocess.maildomain = maildomain2
am.switch_maildomain = switch_maildomain
yield am
if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
if testproc.pytestconfig.getoption("--extra-info"):
logfile = io.StringIO()
am.dump_imap_summary(logfile=logfile)
print(logfile.getvalue())
# request.node.add_report_section("call", "imap-server-state", s)
@pytest.fixture
@@ -395,27 +335,19 @@ class Remote:
def __init__(self, sshdomain):
self.sshdomain = sshdomain
def iter_output(self, logcmd="", ready=None):
def iter_output(self, logcmd=""):
getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain)
match self.sshdomain:
case "@local": command = []
case "localhost": command = []
case _: command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()]
self.popen = subprocess.Popen(
command,
["ssh", f"root@{self.sshdomain}", getjournal],
stdout=subprocess.PIPE,
)
while 1:
line = self.popen.stdout.readline()
res = line.decode().strip().lower()
if not res:
if res:
yield res
else:
break
if ready is not None:
ready()
ready = None
yield res
@pytest.fixture
@@ -431,40 +363,38 @@ def lp(request):
@pytest.fixture
def cmsetup(maildomain, gencreds, ssl_context):
return CMSetup(maildomain, gencreds, ssl_context)
def cmsetup(maildomain, gencreds):
return CMSetup(maildomain, gencreds)
class CMSetup:
def __init__(self, maildomain, gencreds, ssl_context):
def __init__(self, maildomain, gencreds):
self.maildomain = maildomain
self.gencreds = gencreds
self.ssl_context = ssl_context
def gen_users(self, num):
print(f"Creating {num} online users")
users = []
for i in range(num):
addr, password = self.gencreds()
user = CMUser(self.maildomain, addr, password, self.ssl_context)
user = CMUser(self.maildomain, addr, password)
assert user.smtp
users.append(user)
return users
class CMUser:
def __init__(self, maildomain, addr, password, ssl_context=None):
def __init__(self, maildomain, addr, password):
self.maildomain = maildomain
self.addr = addr
self.password = password
self.ssl_context = ssl_context
self._smtp = None
self._imap = None
@property
def smtp(self):
if not self._smtp:
handle = SmtpConn(self.maildomain, ssl_context=self.ssl_context)
handle = SmtpConn(self.maildomain)
handle.connect()
handle.login(self.addr, self.password)
self._smtp = handle
@@ -473,7 +403,7 @@ class CMUser:
@property
def imap(self):
if not self._imap:
imap = ImapConn(self.maildomain, ssl_context=self.ssl_context)
imap = ImapConn(self.maildomain)
imap.connect()
imap.login(self.addr, self.password)
self._imap = imap

View File

@@ -114,16 +114,6 @@ class TestPerformInitialChecks:
assert not res
assert len(l) == 2
def test_perform_initial_checks_no_mta_sts_self_signed(self, mockdns):
del mockdns["CNAME"]["mta-sts.some.domain"]
remote_data = remote.rdns.perform_initial_checks("some.domain")
assert not remote_data["MTA_STS"]
l = []
res = check_initial_remote_data(remote_data, strict_tls=False, print=l.append)
assert res
assert not l
def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
for zf_line in zonefile.split("\n"):

View File

@@ -1,78 +0,0 @@
"""Functional tests for tls_external_cert_and_key option."""
import json
import chatmaild.newemail
import pytest
from chatmaild.config import read_config, write_initial_config
def make_external_config(tmp_path, cert_key=None):
inipath = tmp_path / "chatmail.ini"
overrides = {}
if cert_key is not None:
overrides["tls_external_cert_and_key"] = cert_key
write_initial_config(inipath, "chat.example.org", overrides=overrides)
return inipath
def test_external_tls_config_reads_paths(tmp_path):
inipath = make_external_config(
tmp_path,
cert_key=(
"/etc/letsencrypt/live/chat.example.org/fullchain.pem"
" /etc/letsencrypt/live/chat.example.org/privkey.pem"
),
)
config = read_config(inipath)
assert config.tls_cert_mode == "external"
assert (
config.tls_cert_path == "/etc/letsencrypt/live/chat.example.org/fullchain.pem"
)
assert config.tls_key_path == "/etc/letsencrypt/live/chat.example.org/privkey.pem"
def test_external_tls_missing_option_uses_acme(tmp_path):
config = read_config(make_external_config(tmp_path))
assert config.tls_cert_mode == "acme"
def test_external_tls_bad_format_raises(tmp_path):
inipath = make_external_config(tmp_path, cert_key="/only/one/path.pem")
with pytest.raises(ValueError, match="two space-separated"):
read_config(inipath)
def test_external_tls_three_paths_raises(tmp_path):
inipath = make_external_config(tmp_path, cert_key="/a /b /c")
with pytest.raises(ValueError, match="two space-separated"):
read_config(inipath)
def test_external_tls_no_dclogin_url(tmp_path, capsys, monkeypatch):
inipath = make_external_config(
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
)
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(inipath))
chatmaild.newemail.print_new_account()
out, _ = capsys.readouterr()
lines = out.split("\n")
dic = json.loads(lines[2])
assert "dclogin_url" not in dic
def test_external_tls_selects_correct_deployer(tmp_path):
from cmdeploy.deployers import get_tls_deployer
from cmdeploy.external.deployer import ExternalTlsDeployer
from cmdeploy.selfsigned.deployer import SelfSignedTlsDeployer
inipath = make_external_config(
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
)
config = read_config(inipath)
deployer = get_tls_deployer(config, "chat.example.org")
assert isinstance(deployer, ExternalTlsDeployer)
assert not isinstance(deployer, SelfSignedTlsDeployer)
assert deployer.cert_path == "/certs/fullchain.pem"
assert deployer.key_path == "/certs/privkey.pem"

View File

@@ -1,264 +0,0 @@
Docker installation
===================
This section provides instructions for installing a chatmail relay
using Docker Compose.
.. note::
- Docker support is experimental, CI builds and tests the image automatically, but please report bugs.
- The image wraps the cmdeploy process detailed in the :doc:`getting_started` instructions in a Debian-systemd image with r/w access to `/sys/fs`
- Currently amd64-only (arm64 should work but is untested).
Setup Preparation
-----------------
We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain.
1. Install docker and docker compose v2 (check with `docker compose version`), install, e.g., on
- Debian 12 through the `official install instructions <https://docs.docker.com/engine/install/debian/#install-using-the-repository>`_
- Debian 13+ with `apt install docker docker-compose`
2. Setup the initial DNS records.
The following is an example in the familiar BIND zone file format with
a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses.
::
chat.example.org. 3600 IN A 198.51.100.5
chat.example.org. 3600 IN AAAA 2001:db8::5
www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
3. Configure kernel parameters on the host, as these can not be set from the container::
echo "fs.inotify.max_user_instances=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
echo "fs.inotify.max_user_watches=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
sudo sysctl --system
Docker Compose Setup
--------------------
Pre-built images are available from GitHub Container Registry. The
``main`` branch and tagged releases are pushed automatically by CI::
docker pull ghcr.io/chatmail/relay:main # latest main branch
docker pull ghcr.io/chatmail/relay:1.2.3 # tagged release
Create service directory
^^^^^^^^^^^^^^^^^^^^^^^^
Either:
- Create a service directory and download the compose files::
mkdir -p /srv/chatmail-relay && cd /srv/chatmail-relay
wget https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker/docker-compose.yaml
wget https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker/docker-compose.override.yaml.example -O docker-compose.override.yaml
- or clone the chatmail repo and enter the docker directory::
git clone https://github.com/chatmail/relay
cd relay/docker
Customize and start
^^^^^^^^^^^^^^^^^^^
1. Set the fully qualified domain name of the relay::
echo 'MAIL_DOMAIN=chat.example.org' > .env
The container generates a ``chatmail.ini`` with defaults from
``MAIL_DOMAIN`` on first start. To customize chatmail settings, mount
your own ``chatmail.ini`` instead (see `Custom chatmail.ini`_ below).
2. All local customizations (data paths, extra volumes, config mounts) go in
``docker-compose.override.yaml``, which Compose merges automatically with
the base file. By default, all data is stored in docker volumes, you will
likely want to at least create and configure the mail storage location, but
you might also want to configure external TLS certificates there.
3. Start the container::
docker compose up -d
docker compose logs -f chatmail # view logs, Ctrl+C to exit
4. After installation is complete, open ``https://chat.example.org`` in
your browser.
Finish install and test
-----------------------
You can test the installation with::
pip install cmping chat.example.org # or
uvx cmping chat.example.org # if you use https://docs.astral.sh/uv/
You should check and extend your DNS records for better interoperability::
# Show required DNS records
docker exec chatmail cmdeploy dns --ssh-host @local
You can check server status with::
docker exec chatmail cmdeploy status --ssh-host @local
You can run some benchmarks (can also run from any machine with cmdeploy installed)::
docker exec chatmail cmdeploy bench
You can run the test suite with::
docker exec chatmail cmdeploy test --ssh-host localhost
You can look at logs::
docker exec chatmail journalctl -fu postfix@-
Customization
-------------
Website
^^^^^^^^^^^^^^
You can customize the chatmail landing page by mounting a directory with
your own website source files.
1. Create a directory with your custom website source::
mkdir -p ./custom/www/src
nano ./custom/www/src/index.md
2. Add the volume mount in ``docker-compose.override.yaml``::
services:
chatmail:
volumes:
- ./custom/www:/opt/chatmail-www
3. Restart the service::
docker compose down
docker compose up -d
Custom chatmail.ini
^^^^^^^^^^^^^^^^^^^
If you want to go beyond simply setting the ``MAIL_DOMAIN`` in ``.env``, you
can use a regular `chatmail.ini` to give you full control.
1. Extract the generated config from a running container::
docker cp chatmail:/etc/chatmail/chatmail.ini ./chatmail.ini
2. Edit ``chatmail.ini`` as needed.
3. Add the volume mount in ``docker-compose.override.yaml`` ::
services:
chatmail:
volumes:
- ./chatmail.ini:/etc/chatmail/chatmail.ini
4. Restart the container, the container skips generating a new one: ::
docker compose down && docker compose up -d
External TLS certificates
^^^^^^^^^^^^^^^^^^^^^^^^^
If TLS certificates are managed outside the container (e.g. by certbot,
acmetool, or Traefik on the host), mount them into the container and set
``TLS_EXTERNAL_CERT_AND_KEY`` in ``docker-compose.override.yaml``.
Changed certificates are picked up automatically via inotify.
See the examples in the example override and :ref:`external-tls` in the getting started guide for details.
Migrating from a bare-metal install
------------------------------------
If you have an existing bare-metal chatmail installation and want to
switch to Docker:
1. Stop all existing services::
systemctl stop postfix dovecot doveauth nginx opendkim unbound \
acmetool-redirector filtermail filtermail-incoming chatmail-turn \
iroh-relay chatmail-metadata lastlogin mtail
systemctl disable postfix dovecot doveauth nginx opendkim unbound \
acmetool-redirector filtermail filtermail-incoming chatmail-turn \
iroh-relay chatmail-metadata lastlogin mtail
2. Copy your existing ``chatmail.ini`` and mount it into the container
(see `Custom chatmail.ini`_ above)::
cp /usr/local/lib/chatmaild/chatmail.ini ./chatmail.ini
3. Copy persistent data into the ``./data/`` subdirectories (for example, as configured in `Customize and start`_) ::
mkdir -p data/dkim data/certs data/mail
# DKIM keys
cp -a /etc/dkimkeys/* data/dkim/
# TLS certificates
rsync -a /var/lib/acme/ data/certs/
Note that ownership of dkim and acme is adjusted on container start.
For the mail directory::
rsync -a /home/vmail/ data/mail/
Alternatively, mount ``/home/vmail`` directly by changing the volume
in ``docker-compose-override.yaml``::
- /home/vmail:/home/vmail
The three ``./data/`` subdirectories cover all persistent state.
Everything else is regenerated by the ``configure`` and ``activate``
stages on container start.
Building the image
------------------
Clone the repository and build the Docker image::
git clone https://github.com/chatmail/relay
cd relay
docker/build.sh
The build bakes all binaries, Python packages, and the install stage
into the image. After building, only the ``docker/`` directory and a ``.env``
with ``MAIL_DOMAIN`` are needed to run the container. The `build.sh` passes the
git hash onto the docker build so it can be determined if there has been a
change that warrants a redeploy.
You can transfer a locally built image to your server directly (pigz is parallel `gzip` which can be used instead as well) ::
docker save chatmail-relay:latest | pigz | ssh chat.example.org 'pigz -d | docker load'
Forcing a full reinstall
------------------------
On container start, only the ``configure`` and ``activate`` stages run by default.
To force a full reinstall (e.g. after updating the source), either
rebuild the image::
docker compose build chatmail
docker compose up -d
Or override the stages at runtime without rebuilding::
CMDEPLOY_STAGES="install,configure,activate" docker compose up -d

View File

@@ -47,14 +47,6 @@ steps. Please substitute it with your own domain.
www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
.. note::
For experimental deployments using self-signed certificates,
use a domain name starting with ``_``
(e.g. ``_chat.example.org``).
The ``mta-sts`` CNAME and ``_mta-sts`` TXT records
are not needed for such domains.
2. On your local PC, clone the repository and bootstrap the Python
virtualenv.
@@ -71,16 +63,6 @@ steps. Please substitute it with your own domain.
scripts/cmdeploy init chat.example.org # <-- use your domain
To use self-signed TLS certificates
instead of Let's Encrypt,
use a domain name starting with ``_``
(e.g. ``scripts/cmdeploy init _chat.example.org``).
Domains starting with ``_`` cannot obtain WebPKI certificates,
so self-signed mode is derived automatically.
This is useful for private or test deployments.
See the :doc:`overview`
for details on certificate provisioning.
4. Verify that SSH root login to the deployment server server works:
::
@@ -98,12 +80,6 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are
public).
Docker installation
-------------------
There is experimental support for running chatmail via Docker Compose.
See :doc:`docker` for full setup instructions.
Other helpful commands
----------------------
@@ -193,55 +169,6 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present.
Running a relay with self-signed certificates
----------------------------------------------
Use a domain name starting with ``_`` (e.g. ``_chat.example.org``)
to run a relay with self-signed certificates.
Domains starting with ``_`` cannot obtain WebPKI certificates
so the relay automatically uses self-signed certificates
and all other relays will accept connections from it
without requiring certificate verification.
This is useful for experimental setups and testing.
.. _external-tls:
Running a relay with externally managed certificates
-----------------------------------------------------
If you already have a TLS certificate manager
(e.g. Traefik, certbot, or another ACME client)
running on the deployment server,
you can configure the relay to use those certificates
instead of the built-in ``acmetool``.
Set the following in ``chatmail.ini``::
tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
The paths must point to certificate and key files
on the deployment server.
During ``cmdeploy run``, these paths are written into
the Postfix, Dovecot, and Nginx configurations.
No certificate files are transferred from the build machine —
they must already exist on the server,
managed by your external certificate tool.
The deploy will verify that both files exist on the server.
``acmetool`` is **not** installed or run in this mode.
.. note::
You are responsible for certificate renewal.
When the certificate file changes on disk,
all relay services pick up the new certificate automatically
via a systemd path watcher installed during deploy.
The watcher uses inotify, which does not cross bind-mount boundaries.
If you use such a setup, you must trigger the reload explicitly after renewal::
systemctl start tls-cert-reload.service
Migrating to a new build machine
----------------------------------

View File

@@ -13,7 +13,6 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay
:maxdepth: 5
getting_started
docker
proxy
migrate
overview

View File

@@ -109,6 +109,10 @@ short overview of ``chatmaild`` services:
is contacted by Dovecot when a user logs in and stores the date of
the login.
- `metrics <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metrics.py>`_
collects some metrics and displays them at
``https://example.org/metrics``.
``www/``
~~~~~~~~~
@@ -138,9 +142,11 @@ Chatmail relay dependency diagram
nginx-internal --- autoconfig.xml;
certs-nginx[("`TLS certs
/var/lib/acme`")] --> nginx-internal;
systemd-timer --- chatmail-metrics;
systemd-timer --- acmetool;
systemd-timer --- chatmail-expire-daily;
systemd-timer --- chatmail-fsreport-daily;
chatmail-metrics --- website;
acmetool --> certs[("`TLS certs
/var/lib/acme`")];
nginx-external --- |993|dovecot;
@@ -291,7 +297,8 @@ TLS requirements
Postfix is configured to require valid TLS by setting
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_
to ``verify``.
to ``verify``. If emails dont arrive at your chatmail relay server, the
problem is likely that your relay does not have a valid TLS certificate.
You can test it by resolving ``MX`` records of your relay domain and
then connecting to MX relays (e.g ``mx.example.org``) with
@@ -302,11 +309,6 @@ When providing a TLS certificate to your chatmail relay server, make
sure to provide the full certificate chain and not just the last
certificate.
If you use an external certificate manager (e.g. Traefik or certbot),
set ``tls_external_cert_and_key`` in ``chatmail.ini``
to provide the certificate and key paths.
See :ref:`external-tls` for details.
If you are running an Exim server and dont see incoming connections
from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log
item is enabled in the config with ``log_selector = +smtp_no_mail``. By
@@ -315,14 +317,6 @@ default Exim does not log sessions that are closed before sending the
by Postfix, so you might think that connection is not established while
actually it is a problem with your TLS certificate.
If emails dont arrive at your chatmail relay server, the
problem is likely that your relay does not have a valid TLS certificate.
Note that connections to relays with underscore-prefixed test domains
(e.g. ``_chat.example.org``) use ``encrypt`` tls security level,
because such domains cannot obtain valid Let's Encrypt certificates
and run with self-signed certificates.
.. _dovecot: https://dovecot.org
.. _postfix: https://www.postfix.org

View File

@@ -14,8 +14,8 @@ We know of three work-in-progress alternative implementation efforts:
it to support all of the features and configuration settings required
to operate as a chatmail relay.
- `Madmail <https://github.com/themadorg/madmail>`_: an
experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified
- `Madmail <https://github.com/omidz4t/madmail>`_: an
experimental fork of Maddy Mail Server <https://maddy.email/>`_ optimized
for chatmail deployments. It provides a single binary solution
for running a chatmail relay.

View File

@@ -1,9 +0,0 @@
#!/bin/sh
# Build the chatmail Docker image with the current git hash baked in.
# Usage: ./docker/build.sh [extra docker-compose build args...]
#
# .git/ is excluded from the build context (.dockerignore) so the hash
# must be passed as a build arg from the host.
export GIT_HASH=$(git rev-parse HEAD)
exec docker compose -f docker/docker-compose.yaml build "$@"

View File

@@ -1,14 +0,0 @@
[Unit]
Description=Run container setup commands
After=multi-user.target
ConditionPathExists=/chatmail-init.sh
[Service]
Type=oneshot
ExecStart=/bin/bash /chatmail-init.sh
RemainAfterExit=true
WorkingDirectory=/opt/chatmail
PassEnvironment=<envs_list>
[Install]
WantedBy=multi-user.target

View File

@@ -1,85 +0,0 @@
#!/bin/bash
set -euo pipefail
export CHATMAIL_INI="${CHATMAIL_INI:-/etc/chatmail/chatmail.ini}"
CMDEPLOY=/opt/cmdeploy/bin/cmdeploy
if [ -z "$MAIL_DOMAIN" ]; then
echo "ERROR: Environment variable 'MAIL_DOMAIN' must be set!" >&2
exit 1
fi
# Generate DKIM keys if not mounted
if [ ! -f /etc/dkimkeys/opendkim.private ]; then
/usr/sbin/opendkim-genkey -D /etc/dkimkeys -d "$MAIL_DOMAIN" -s opendkim
fi
# Fix ownership for bind-mounted keys (host opendkim UID may differ from container)
chown -R opendkim:opendkim /etc/dkimkeys
# Create chatmail.ini, skip if mounted
mkdir -p "$(dirname "$CHATMAIL_INI")"
if [ ! -f "$CHATMAIL_INI" ]; then
$CMDEPLOY init --config "$CHATMAIL_INI" "$MAIL_DOMAIN"
fi
# Auto-detect IPv6: if the host has no IPv6 connectivity, set disable_ipv6
# in the ini so dovecot/postfix/nginx bind to IPv4 only.
# Uses network_mode:host so /proc/net/if_inet6 reflects the host's stack.
if [ ! -e /proc/net/if_inet6 ]; then
if grep -q '^disable_ipv6 = False' "$CHATMAIL_INI"; then
sed -i 's/^disable_ipv6 = False/disable_ipv6 = True/' "$CHATMAIL_INI"
echo "[INFO] IPv6 not available, set disable_ipv6 = True"
fi
fi
# Inject external TLS paths from env var unless defined in chatmail.ini
if [ -n "${TLS_EXTERNAL_CERT_AND_KEY:-}" ]; then
if ! grep -q '^tls_external_cert_and_key' "$CHATMAIL_INI"; then
echo "tls_external_cert_and_key = $TLS_EXTERNAL_CERT_AND_KEY" >> "$CHATMAIL_INI"
fi
fi
# Ensure mailboxes directory exists (chatmail-metadata needs it at startup,
# but Dovecot only creates it on first mail delivery)
mkdir -p "/home/vmail/mail/${MAIL_DOMAIN}"
chown vmail:vmail "/home/vmail/mail/${MAIL_DOMAIN}"
# --- Deploy fingerprint: skip cmdeploy run if nothing changed ---
# On restart with identical image+config, systemd already brings up all
# enabled services only configure+activate are needed here.
IMAGE_VERSION_FILE="/etc/chatmail-image-version"
FINGERPRINT_FILE="/etc/chatmail/.deploy-fingerprint"
image_ver="none"
[ -f "$IMAGE_VERSION_FILE" ] && image_ver=$(cat "$IMAGE_VERSION_FILE")
config_hash=$(sha256sum "$CHATMAIL_INI" | cut -c1-16)
current_fp="${image_ver}:${config_hash}"
# CMDEPLOY_STAGES non-empty in env = operator override -> always run.
# Otherwise, if fingerprint matches the last successful deploy, skip.
if [ -z "${CMDEPLOY_STAGES:-}" ] \
&& [ -f "$FINGERPRINT_FILE" ] \
&& [ "$(cat "$FINGERPRINT_FILE")" = "$current_fp" ]; then
echo "[INFO] No changes detected ($current_fp), skipping deploy."
else
export CMDEPLOY_STAGES="${CMDEPLOY_STAGES:-configure,activate}"
# Skip DNS check when MAIL_DOMAIN is a bare IP address
SKIP_DNS=""
if [[ "$MAIL_DOMAIN" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || [[ "$MAIL_DOMAIN" =~ : ]]; then
SKIP_DNS="--skip-dns-check"
fi
$CMDEPLOY run --config "$CHATMAIL_INI" --ssh-host @local $SKIP_DNS
# Restore the build-time hash
cp /etc/chatmail-image-version /etc/chatmail-version
echo "$current_fp" > "$FINGERPRINT_FILE"
fi
# Signal success to Docker healthcheck
touch /run/chatmail-init.done
# Forward journald to console so `docker compose logs` works
grep -q '^ForwardToConsole=yes' /etc/systemd/journald.conf \
|| echo "ForwardToConsole=yes" >> /etc/systemd/journald.conf
systemctl restart systemd-journald

View File

@@ -1,108 +0,0 @@
# syntax=docker/dockerfile:1
FROM jrei/systemd-debian:12 AS base
ENV LANG=en_US.UTF-8
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/01norecommend && \
echo 'APT::Install-Suggests "0";' >> /etc/apt/apt.conf.d/01norecommend && \
apt-get update && \
DEBIAN_FRONTEND=noninteractive TZ=UTC \
apt-get install -y \
ca-certificates \
gcc \
git \
python3 \
python3-dev \
python3-venv \
tzdata \
locales && \
sed -i -e "s/# $LANG.*/$LANG UTF-8/" /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=$LANG
# --- Build-time: install cmdeploy venv and run install stage ---
# Editable install so importlib.resources reads directly from the source tree.
# On container start only "configure,activate" stages run.
# Copy dependency metadata first so pip install layer is cached
COPY cmdeploy/pyproject.toml /opt/chatmail/cmdeploy/pyproject.toml
COPY chatmaild/pyproject.toml /opt/chatmail/chatmaild/pyproject.toml
# Dummy scaffolding so editable install can discover packages
RUN mkdir -p /opt/chatmail/cmdeploy/src/cmdeploy \
/opt/chatmail/chatmaild/src/chatmaild && \
touch /opt/chatmail/cmdeploy/src/cmdeploy/__init__.py \
/opt/chatmail/chatmaild/src/chatmaild/__init__.py
# Dummy git repo: .git/ is excluded from the build context (.dockerignore)
# but setuptools calls `git ls-files` when building the sdist.
WORKDIR /opt/chatmail
RUN --mount=type=cache,target=/root/.cache/pip \
git init -q && \
python3 -m venv /opt/cmdeploy && \
/opt/cmdeploy/bin/pip install -e chatmaild/ -e cmdeploy/
# Full source copy (editable install's .egg-link still points here)
COPY . /opt/chatmail/
# Minimal chatmail.ini
RUN printf '[params]\nmail_domain = build.local\n' > /tmp/chatmail.ini
RUN CMDEPLOY_STAGES=install \
CHATMAIL_INI=/tmp/chatmail.ini \
/opt/cmdeploy/bin/pyinfra @local \
/opt/chatmail/cmdeploy/src/cmdeploy/run.py -y
RUN cp -a www/ /opt/chatmail-www/
# Remove build-only packages — not needed at runtime.
# Keep git: test_deployed_state needs `git rev-parse HEAD` to verify the
# deployed version hash matches /etc/chatmail-version.
RUN apt-get purge -y gcc python3-dev && \
apt-get autoremove -y && \
rm -f /tmp/chatmail.ini
# Record image version (used in deploy fingerprint at runtime).
# GIT_HASH is passed as a build arg (from docker-compose or CI) so that
# .git/ can be excluded from the build context via .dockerignore.
# Two files: chatmail-image-version is the immutable build hash (survives
# deploys); chatmail-version is overwritten by cmdeploy run and restored
# from the image version after each deploy in chatmail-init.sh.
ARG GIT_HASH=unknown
RUN echo "$GIT_HASH" > /etc/chatmail-image-version && \
echo "$GIT_HASH" > /etc/chatmail-version
# Mock git HEAD so `git rev-parse HEAD` returns the source repo's commit hash.
# The .git/ dir was created by `git init` earlier (for setuptools); we just
# write the build hash into whatever branch HEAD points to.
RUN head_ref=$(sed 's/^ref: //' /opt/chatmail/.git/HEAD) && \
mkdir -p "/opt/chatmail/.git/$(dirname "$head_ref")" && \
echo "$GIT_HASH" > "/opt/chatmail/.git/$head_ref"
# --- End build-time install ---
ENV TZ=:/etc/localtime
ENV PATH="/opt/cmdeploy/bin:${PATH}"
RUN ln -s /etc/chatmail/chatmail.ini /opt/chatmail/chatmail.ini
ARG CHATMAIL_INIT_SERVICE_PATH=/lib/systemd/system/chatmail-init.service
COPY ./docker/chatmail-init.service "$CHATMAIL_INIT_SERVICE_PATH"
RUN ln -sf "$CHATMAIL_INIT_SERVICE_PATH" "/etc/systemd/system/multi-user.target.wants/chatmail-init.service"
# Remove default nginx site config at build time (not in entrypoint)
RUN rm -f /etc/nginx/sites-enabled/default
COPY --chmod=555 ./docker/chatmail-init.sh /chatmail-init.sh
COPY --chmod=555 ./docker/entrypoint.sh /entrypoint.sh
COPY --chmod=555 ./docker/healthcheck.sh /healthcheck.sh
HEALTHCHECK --interval=10s --start-period=180s --timeout=10s --retries=3 \
CMD /healthcheck.sh
STOPSIGNAL SIGRTMIN+3
ENTRYPOINT ["/entrypoint.sh"]
CMD [ "--default-standard-output=journal+console", \
"--default-standard-error=journal+console" ]

View File

@@ -1,70 +0,0 @@
# Traefik reverse-proxy example — use as a compose override:
#
# docker compose -f docker-compose.yaml -f docker-compose-traefik.yaml up -d
#
# Traefik handles HTTP→HTTPS redirect and ACME certificate issuance.
# traefik-certs-dumper extracts the certificates to the filesystem so
# chatmail's Postfix/Dovecot/nginx can use them via TLS_EXTERNAL_CERT_AND_KEY.
#
# Prerequisites:
# mkdir -p traefik/data traefik/dynamic-configs
# touch traefik/data/acme.json && chmod 600 traefik/data/acme.json
# cp traefik/config.yaml.example traefik/config.yaml # see below
#
# Required .env variables (in addition to MAIL_DOMAIN):
# ACME_EMAIL=admin@example.org
services:
chatmail:
environment:
# Point chatmail at the certs dumped by traefik-certs-dumper.
# The container's tls-cert-reload.path watches for changes.
TLS_EXTERNAL_CERT_AND_KEY: >-
/traefik-certs/${MAIL_DOMAIN}/certificate.crt
/traefik-certs/${MAIL_DOMAIN}/privatekey.key
volumes:
- traefik-certs:/traefik-certs:ro
depends_on:
- traefik-certs-dumper
labels:
- traefik.enable=true
- traefik.http.services.chatmail.loadbalancer.server.scheme=https
- traefik.http.services.chatmail.loadbalancer.server.port=443
- traefik.http.routers.chatmail.rule=Host(`${MAIL_DOMAIN}`) || Host(`mta-sts.${MAIL_DOMAIN}`) || Host(`www.${MAIL_DOMAIN}`)
- traefik.http.routers.chatmail.tls=true
- traefik.http.routers.chatmail.tls.certresolver=letsEncrypt
traefik:
image: traefik:v3.3
container_name: traefik
restart: unless-stopped
network_mode: host
command:
- "--configFile=/config.yaml"
- "--certificatesresolvers.letsEncrypt.acme.email=${ACME_EMAIL}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/config.yaml:/config.yaml:ro
- ./traefik/data/acme.json:/acme.json
- ./traefik/dynamic-configs:/dynamic/conf:ro
traefik-certs-dumper:
image: ldez/traefik-certs-dumper:v2.10.0
restart: unless-stopped
depends_on:
- traefik
entrypoint: sh -c '
apk add openssl
&& while ! [ -e /data/acme.json ]
|| ! [ $$(jq ".[] | .Certificates | length" /data/acme.json | jq -s "add") != 0 ]; do
sleep 1;
done
&& traefik-certs-dumper file
--version v3 --watch --domain-subdir=true
--source /data/acme.json --dest /certs'
volumes:
- ./traefik/data/acme.json:/data/acme.json:ro
- traefik-certs:/certs
volumes:
traefik-certs:

View File

@@ -1,11 +0,0 @@
# Used by .github/workflows/docker-ci.yaml
# The GHCR image is set via CHATMAIL_IMAGE env var at deploy time.
services:
chatmail:
image: ${CHATMAIL_IMAGE:-chatmail-relay:latest}
volumes:
- /srv/chatmail/chatmail.ini:/etc/chatmail/chatmail.ini
- /srv/chatmail/dkim:/etc/dkimkeys
- /srv/chatmail/certs:/var/lib/acme
environment:
TLS_EXTERNAL_CERT_AND_KEY: /var/lib/acme/live/${MAIL_DOMAIN}/fullchain /var/lib/acme/live/${MAIL_DOMAIN}/privkey

View File

@@ -1,44 +0,0 @@
# Local overrides: copy to docker-compose.override.yaml in this directory.
# Compose automatically merges this with docker-compose.yaml.
#
# cp docker-compose.override.yaml.example docker-compose.override.yaml
#
# Volumes are APPENDED to the base file's volumes list, environment and other scalar keys are MERGED by key.
services:
chatmail:
volumes:
## Data paths — bind-mount to host directories for easy access/backup.
# - ./data/dkim:/etc/dkimkeys
# - ./data/certs:/var/lib/acme
# - ./data/mail:/home/vmail
## Or mount from an existing bare-metal install.
# - /home/vmail:/home/vmail
## Mount your own chatmail.ini (skips auto-generation):
# - ./chatmail.ini:/etc/chatmail/chatmail.ini
## Custom website:
# - ./custom/www:/opt/chatmail-www
## Debug — mount scripts for live editing:
# - ./chatmail-init.sh:/chatmail-init.sh
# - ./entrypoint.sh:/entrypoint.sh
# environment:
## Mount certs (above) and set TLS_EXTERNAL_CERT_AND_KEY to in-container paths.
## A tls-cert-reload.path watcher inside the container reloads services
## when the cert file changes. However, inotify does not cross bind-mount
## boundaries, so host-side renewals (certbot, acmetool, etc.) must
## notify the container explicitly. Add this to your renewal hook:
##
## docker exec chatmail systemctl start tls-cert-reload.service
##
## Host acmetool (bare-metal migration): create mount above, and
## rsync -a /var/lib/acme/live data/certs
# TLS_EXTERNAL_CERT_AND_KEY: "/var/lib/acme/live/${MAIL_DOMAIN}/fullchain /var/lib/acme/live/${MAIL_DOMAIN}/privkey"
##
## (Untested) Traefik certs-dumper (see docker/docker-compose-traefik.yaml) - also add volume:
## - traefik-certs:/certs:ro
# TLS_EXTERNAL_CERT_AND_KEY: "/certs/${MAIL_DOMAIN}/certificate.crt /certs/${MAIL_DOMAIN}/privatekey.key"

View File

@@ -1,48 +0,0 @@
# Base compose file — do not edit. Put customizations (data paths, extra
# volumes, env overrides) in docker-compose.override.yaml instead.
# See docker-compose.override.yaml.example in this directory for a starting point.
#
# Security notes: this container uses
# - network_mode:host chatmail needs many ports (25, 53, 80, 143, 443, 465,
# 587, 993, 3340, 8443) and needs to operate from the real IP, which bridging
# would make tricky
# - cgroup:host (required for systemd).
# Together these give the container near-host-level access. This is acceptable
# for a dedicated mail server, but be aware that the container can bind any
# port and see all host network traffic.
services:
chatmail:
build:
context: ../
dockerfile: docker/chatmail_relay.dockerfile
args:
GIT_HASH: ${GIT_HASH:-unknown}
image: chatmail-relay:latest
restart: unless-stopped
container_name: chatmail
# Required for systemd — use only one of the following:
cgroup: host # compose v2
# privileged: true # compose v1 (less restricted)
tty: true # required for logs
tmpfs: # required for systemd
- /tmp
- /run
- /run/lock
logging:
driver: none
environment:
MAIL_DOMAIN: $MAIL_DOMAIN
network_mode: "host"
volumes:
## system (required)
- /sys/fs/cgroup:/sys/fs/cgroup:rw
## data (defaults — override in docker-compose.override.yaml)
- mail:/home/vmail
- dkim:/etc/dkimkeys
- certs:/var/lib/acme
volumes:
mail:
dkim:
certs:

View File

@@ -1,9 +0,0 @@
#!/bin/bash
set -eo pipefail
CHATMAIL_INIT_SERVICE_PATH="${CHATMAIL_INIT_SERVICE_PATH:-/lib/systemd/system/chatmail-init.service}"
env_vars="MAIL_DOMAIN CMDEPLOY_STAGES CHATMAIL_INI TLS_EXTERNAL_CERT_AND_KEY PATH"
sed -i "s|<envs_list>|$env_vars|g" "$CHATMAIL_INIT_SERVICE_PATH"
exec /lib/systemd/systemd "$@"

View File

@@ -1 +0,0 @@
MAIL_DOMAIN=chat.example.com

View File

@@ -1,16 +0,0 @@
#!/bin/bash
# returns 0 when chatmail-init succeeded and all expected services are running.
set -e
test -f /run/chatmail-init.done
# Core services
services="chatmail-metadata doveauth dovecot filtermail filtermail-incoming nginx postfix unbound"
# Optional services
for svc in iroh-relay turnserver; do
systemctl is-enabled "$svc" 2>/dev/null && services="$services $svc"
done
exec systemctl is-active $services

View File

@@ -1,21 +0,0 @@
/* dclogin profile generator for self-signed chatmail relays.
* Fetches credentials from /new and generates a dclogin: QR code.
* Requires qrcode-svg.min.js to be loaded first.
*/
(function () {
function generateProfile() {
fetch('/new')
.then(function (r) { return r.json(); })
.then(function (data) {
var url = data.dclogin_url;
var link = document.getElementById('dclogin-link');
link.href = url;
var qrLink = document.getElementById('qr-link');
qrLink.href = url;
var qrCode = document.getElementById('qr-code');
var qr = new QRCode({ content: url, width: 300, height: 300, padding: 1, join: true });
qrCode.innerHTML = qr.svg();
});
}
generateProfile();
})();

View File

@@ -11,18 +11,6 @@ for Delta Chat users. For details how it avoids storing personal information
please see our [privacy policy](privacy.html).
{% endif %}
{% if config.tls_cert_mode == "self" %}
<a class="cta-button" id="dclogin-link" href="#">Get a {{config.mail_domain}} chat profile</a>
If you are viewing this page on a different device
without a Delta Chat app,
you can also **scan this QR code** with Delta Chat:
<a id="qr-link" href="#"><div id="qr-code"></div></a>
<script src="qrcode-svg.min.js"></script>
<script src="dclogin.js"></script>
{% else %}
<a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a>
If you are viewing this page on a different device
@@ -31,7 +19,6 @@ you can also **scan this QR code** with Delta Chat:
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
{% endif %}
🐣 **Choose** your Avatar and Name

File diff suppressed because one or more lines are too long