diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 76268be..b368591 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -30,6 +30,7 @@ jobs:
operator: ${{ steps.diff.outputs.operator }}
website: ${{ steps.diff.outputs.website }}
platform_api: ${{ steps.diff.outputs.platform_api }}
+ zpush: ${{ steps.diff.outputs.zpush }}
infra: ${{ steps.diff.outputs.infra }}
deploy_any: ${{ steps.diff.outputs.deploy_any }}
steps:
@@ -62,8 +63,9 @@ jobs:
hit '^apps/operator/' && out operator true || out operator false
hit '^apps/website/' && out website true || out website false
hit '^services/platform-api/' && out platform_api true || out platform_api false
+ hit '^services/zpush/' && out zpush true || out zpush false
hit '^infrastructure/production/fleet/apps/' && out infra true || out infra false
- if hit '^apps/(portal|booking|operator)/|^services/platform-api/|^infrastructure/production/fleet/apps/'; then
+ if hit '^apps/(portal|booking|operator)/|^services/(platform-api|zpush)/|^infrastructure/production/fleet/apps/'; then
out deploy_any true
else
out deploy_any false
@@ -212,12 +214,28 @@ jobs:
docker push "$IMG:latest"
docker push "$IMG:${{ github.sha }}"
+ # zpush is PHP (Z-Push EAS gateway) — no typecheck/test stage; the docker
+ # build is the only gate.
+ build_zpush:
+ runs-on: ubuntu-latest
+ needs: [changes]
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.zpush == 'true'
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build + push
+ run: |
+ echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin
+ IMG=git.lastcloud.io/ronnibaslund/dezky/zpush
+ docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" services/zpush
+ docker push "$IMG:latest"
+ docker push "$IMG:${{ github.sha }}"
+
# ── Deploy (only what changed) ─────────────────────────────────────────────
# always() so skipped builds don't block it; the explicit result checks make
# any FAILED build (or its typecheck/test, transitively) abort the deploy.
deploy:
runs-on: ubuntu-latest
- needs: [changes, build_portal, build_booking, build_operator, build_platform_api]
+ needs: [changes, build_portal, build_booking, build_operator, build_platform_api, build_zpush]
if: >-
always() &&
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
@@ -225,7 +243,8 @@ jobs:
needs.build_portal.result != 'failure' && needs.build_portal.result != 'cancelled' &&
needs.build_booking.result != 'failure' && needs.build_booking.result != 'cancelled' &&
needs.build_operator.result != 'failure' && needs.build_operator.result != 'cancelled' &&
- needs.build_platform_api.result != 'failure' && needs.build_platform_api.result != 'cancelled'
+ needs.build_platform_api.result != 'failure' && needs.build_platform_api.result != 'cancelled' &&
+ needs.build_zpush.result != 'failure' && needs.build_zpush.result != 'cancelled'
steps:
- uses: actions/checkout@v4
@@ -246,6 +265,7 @@ jobs:
BUILT_BOOKING: ${{ needs.build_booking.result }}
BUILT_OPERATOR: ${{ needs.build_operator.result }}
BUILT_PLATFORM_API: ${{ needs.build_platform_api.result }}
+ BUILT_ZPUSH: ${{ needs.build_zpush.result }}
run: |
export KUBECONFIG=/tmp/kubeconfig
echo "$KUBECONFIG_B64" | base64 -d > "$KUBECONFIG"
@@ -256,12 +276,14 @@ jobs:
[booking]=${{ needs.changes.outputs.booking }}
[operator]=${{ needs.changes.outputs.operator }}
[platform-api]=${{ needs.changes.outputs.platform_api }}
+ [zpush]=${{ needs.changes.outputs.zpush }}
)
declare -A BUILT=(
[portal]=$BUILT_PORTAL
[booking]=$BUILT_BOOKING
[operator]=$BUILT_OPERATOR
[platform-api]=$BUILT_PLATFORM_API
+ [zpush]=$BUILT_ZPUSH
)
# Deployable = changed AND its image was actually built this run.
deployable() { [ "${CHANGED[$1]}" = "true" ] && [ "${BUILT[$1]}" = "success" ]; }
@@ -271,7 +293,7 @@ jobs:
# app to its CURRENT live image (or this push's sha if it was
# rebuilt) so the apply never resets anything to :latest.
cd infrastructure/production/fleet/apps
- for app in portal booking platform-api operator; do
+ for app in portal booking platform-api operator zpush; do
if deployable "$app"; then
IMG="$REG/$app:$SHA"
else
@@ -280,10 +302,10 @@ jobs:
kustomize edit set image "$REG/$app=$IMG"
done
kubectl apply -k .
- ROLL="portal booking platform-api operator"
+ ROLL="portal booking platform-api operator zpush"
else
# App-only change: roll just the changed apps; nothing else moves.
- for app in portal booking platform-api operator; do
+ for app in portal booking platform-api operator zpush; do
if deployable "$app"; then
kubectl -n dezky-apps set image "deploy/$app" "$app=$REG/$app:$SHA"
ROLL="$ROLL $app"
diff --git a/infrastructure/docker-compose/docker-compose.yml b/infrastructure/docker-compose/docker-compose.yml
index fb6ec25..f075fcb 100644
--- a/infrastructure/docker-compose/docker-compose.yml
+++ b/infrastructure/docker-compose/docker-compose.yml
@@ -29,6 +29,7 @@ volumes:
ocis_config:
ocis_data:
ocis_audit_log:
+ zpush_state:
portal_node_modules:
platform_api_node_modules:
operator_node_modules:
@@ -67,6 +68,7 @@ services:
- api.dezky.local
- files.dezky.local
- mail.dezky.local
+ - autodiscover.dezky.local
- office.dezky.local
- collaboration.dezky.local
labels:
@@ -277,7 +279,8 @@ services:
condition: service_healthy
# ─────────────────────────────────────────────────────────────────
- # Stalwart Mail — Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV/ActiveSync)
+ # Stalwart Mail — Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV;
+ # ActiveSync comes from the zpush gateway above)
# ─────────────────────────────────────────────────────────────────
stalwart:
image: stalwartlabs/stalwart:v0.16
@@ -309,6 +312,43 @@ services:
- traefik.http.routers.stalwart.tls=true
- traefik.http.services.stalwart.loadbalancer.server.port=8080
+ # ─────────────────────────────────────────────────────────────────
+ # Z-Push — Exchange ActiveSync gateway in front of Stalwart. Gives
+ # "Exchange" accounts on iOS/Android native Mail/Calendar two-way
+ # mail+calendar+contacts sync (EAS 14.1 — NOT the Outlook mobile app,
+ # which requires EAS 16.1, and not new Outlook for Windows). Stateless
+ # wrt credentials: passes the client's Basic auth straight to Stalwart.
+ # ─────────────────────────────────────────────────────────────────
+ zpush:
+ build: ../../services/zpush
+ container_name: dezky-zpush
+ restart: unless-stopped
+ environment:
+ CALDAV_SERVER: stalwart
+ IMAP_SERVER: stalwart
+ # Hostname devices get pointed at by EAS autodiscover responses.
+ ZPUSH_HOST: mail.dezky.local
+ # Where outlook-schema (mail) autodiscover POSTs are proxied to.
+ MAIL_AUTODISCOVER_UPSTREAM: http://stalwart:8080
+ volumes:
+ - zpush_state:/var/lib/z-push
+ networks: [dezky]
+ depends_on:
+ - stalwart
+ labels:
+ - traefik.enable=true
+ # More-specific rule outranks the plain Host(`mail.dezky.local`)
+ # stalwart router; priority pins it explicitly.
+ - traefik.http.routers.zpush.rule=Host(`mail.dezky.local`) && PathPrefix(`/Microsoft-Server-ActiveSync`)
+ - traefik.http.routers.zpush.priority=100
+ - traefik.http.routers.zpush.tls=true
+ - traefik.http.services.zpush.loadbalancer.server.port=80
+ # EAS autodiscover for dev. The same dispatcher proxies outlook-schema
+ # mail autodiscover through to Stalwart.
+ - traefik.http.routers.zpush-autodiscover.rule=Host(`autodiscover.dezky.local`)
+ - traefik.http.routers.zpush-autodiscover.tls=true
+ - traefik.http.routers.zpush-autodiscover.service=zpush
+
# ─────────────────────────────────────────────────────────────────
# OCIS — File storage with S3-compatible backend
# ─────────────────────────────────────────────────────────────────
diff --git a/infrastructure/production/fleet/apps/kustomization.yaml b/infrastructure/production/fleet/apps/kustomization.yaml
index c2d0531..024cb6f 100644
--- a/infrastructure/production/fleet/apps/kustomization.yaml
+++ b/infrastructure/production/fleet/apps/kustomization.yaml
@@ -8,6 +8,7 @@ resources:
- namespace.yaml
- redirect-middleware.yaml
- mail-autodiscovery.yaml
+ - zpush.yaml
- platform-api-config.yaml
- platform-api.yaml
- portal.yaml
diff --git a/infrastructure/production/fleet/apps/mail-autodiscovery.yaml b/infrastructure/production/fleet/apps/mail-autodiscovery.yaml
index 9cc5ec4..f153b04 100644
--- a/infrastructure/production/fleet/apps/mail-autodiscovery.yaml
+++ b/infrastructure/production/fleet/apps/mail-autodiscovery.yaml
@@ -1,9 +1,13 @@
-# Mail-client autodiscovery for dezky.eu — routes ONLY the discovery paths
-# through Traefik to host-Stalwart's HTTP listener (10.42.0.1:8080):
+# Mail-client autodiscovery for dezky.eu — routes ONLY the discovery paths:
#
-# autodiscover.dezky.eu POST /autodiscover/autodiscover.xml (Outlook)
+# autodiscover.dezky.eu POST /autodiscover/autodiscover.xml (Outlook + EAS)
# autoconfig.dezky.eu GET /mail/config-v1.1.xml (Thunderbird)
#
+# autodiscover.dezky.eu goes to the zpush dispatcher (zpush.yaml), which
+# answers mobilesync-schema requests itself (Exchange ActiveSync devices)
+# and proxies outlook-schema (mail) requests through to host-Stalwart's
+# HTTP listener unchanged. autoconfig.dezky.eu still hits Stalwart directly.
+#
# Everything else on these hostnames (Stalwart's /admin, /login, /jmap …)
# falls through to Traefik's 404 — the management surface stays internal.
# No HTTPS-redirect middleware on purpose: Thunderbird probes plain HTTP and
@@ -44,6 +48,59 @@ subsets:
- name: http
port: 8080
---
+# IMAPS + SMTPS for the zpush EAS gateway (zpush.yaml) — same
+# selectorless-Service-to-host pattern as stalwart-http above. Host-Stalwart
+# exposes ONLY the implicit-TLS variants (993/465; no plain 143/587 —
+# verified on node1 2026-06-12), all bound to the host wildcard, so the
+# cni0 gateway address reaches them just like :8080.
+apiVersion: v1
+kind: Service
+metadata:
+ name: stalwart-imaps
+ labels:
+ app.kubernetes.io/name: stalwart-imaps
+ app.kubernetes.io/part-of: dezky
+spec:
+ ports:
+ - name: imaps
+ port: 993
+ targetPort: 993
+---
+apiVersion: v1
+kind: Endpoints
+metadata:
+ name: stalwart-imaps
+subsets:
+ - addresses:
+ - ip: 10.42.0.1
+ ports:
+ - name: imaps
+ port: 993
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: stalwart-smtps
+ labels:
+ app.kubernetes.io/name: stalwart-smtps
+ app.kubernetes.io/part-of: dezky
+spec:
+ ports:
+ - name: smtps
+ port: 465
+ targetPort: 465
+---
+apiVersion: v1
+kind: Endpoints
+metadata:
+ name: stalwart-smtps
+subsets:
+ - addresses:
+ - ip: 10.42.0.1
+ ports:
+ - name: smtps
+ port: 465
+---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -62,13 +119,14 @@ spec:
- host: autodiscover.dezky.eu
http:
paths:
- # Outlook probes both capitalizations.
+ # Outlook probes both capitalizations. zpush dispatches by schema:
+ # mobilesync → Z-Push, outlook (mail) → proxied to Stalwart.
- path: /autodiscover/autodiscover.xml
pathType: Exact
- backend: { service: { name: stalwart-http, port: { number: 8080 } } }
+ backend: { service: { name: zpush, port: { number: 80 } } }
- path: /Autodiscover/Autodiscover.xml
pathType: Exact
- backend: { service: { name: stalwart-http, port: { number: 8080 } } }
+ backend: { service: { name: zpush, port: { number: 80 } } }
- host: autoconfig.dezky.eu
http:
paths:
diff --git a/infrastructure/production/fleet/apps/zpush.yaml b/infrastructure/production/fleet/apps/zpush.yaml
new file mode 100644
index 0000000..14270ac
--- /dev/null
+++ b/infrastructure/production/fleet/apps/zpush.yaml
@@ -0,0 +1,150 @@
+# zpush — Exchange ActiveSync gateway (Z-Push, AGPLv3) in front of host-
+# Stalwart. Gives "Exchange" accounts on iOS/Android native Mail/Calendar
+# two-way mail+calendar+contacts sync. EAS 14.1: covers native mobile
+# clients, NOT the Outlook mobile app (requires EAS 16.1) and not new
+# Outlook for Windows (no EAS at all). Credentials pass through to Stalwart
+# per-request (mailbox password or app password) — the pod stores only sync
+# state, which is disposable (devices resync after a wipe).
+#
+# replicas MUST stay 1: Z-Push's FILE state machine is not multi-writer-safe
+# (same constraint family as the portal/operator session pinning).
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: zpush-state
+ namespace: dezky-apps
+ labels:
+ app.kubernetes.io/name: zpush
+ app.kubernetes.io/part-of: dezky
+spec:
+ accessModes: [ReadWriteOnce]
+ storageClassName: local-path
+ resources:
+ requests:
+ storage: 1Gi
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: zpush
+ namespace: dezky-apps
+ labels:
+ app.kubernetes.io/name: zpush
+ app.kubernetes.io/part-of: dezky
+spec:
+ replicas: 1
+ # Recreate, not RollingUpdate: the state PVC is RWO and the state machine
+ # must never have two writers.
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: zpush
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: zpush
+ spec:
+ containers:
+ - name: zpush
+ # CI pins this to the commit SHA at deploy time; :latest is the fallback.
+ image: git.lastcloud.io/ronnibaslund/dezky/zpush:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - name: http
+ containerPort: 80
+ env:
+ # Host-Stalwart reached via the selectorless Services pinning the
+ # cni0 gateway address (see mail-autodiscovery.yaml).
+ - name: CALDAV_SERVER
+ value: stalwart-http.dezky-apps.svc.cluster.local
+ - name: CALDAV_PORT
+ value: "8080"
+ # Host-Stalwart only exposes implicit-TLS IMAPS/SMTPS (no plain
+ # 143/587) — hence /ssl with novalidate-cert (the cert names
+ # mail.dezky.eu, not the cluster service) and the ssl:// prefix.
+ - name: IMAP_SERVER
+ value: stalwart-imaps.dezky-apps.svc.cluster.local
+ - name: IMAP_PORT
+ value: "993"
+ - name: IMAP_OPTIONS
+ value: /ssl/novalidate-cert
+ - name: SMTP_SERVER
+ value: ssl://stalwart-smtps.dezky-apps.svc.cluster.local
+ - name: SMTP_PORT
+ value: "465"
+ # Hostname EAS autodiscover responses point devices at.
+ - name: ZPUSH_HOST
+ value: mail.dezky.eu
+ # Outlook-schema (mail) autodiscover POSTs proxied to Stalwart.
+ - name: MAIL_AUTODISCOVER_UPSTREAM
+ value: http://stalwart-http.dezky-apps.svc.cluster.local:8080
+ volumeMounts:
+ - name: state
+ mountPath: /var/lib/z-push
+ resources:
+ requests:
+ cpu: 100m
+ memory: 192Mi
+ limits:
+ memory: 512Mi
+ readinessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 15
+ livenessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 15
+ periodSeconds: 30
+ volumes:
+ - name: state
+ persistentVolumeClaim:
+ claimName: zpush-state
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: zpush
+ namespace: dezky-apps
+ labels:
+ app.kubernetes.io/name: zpush
+spec:
+ selector:
+ app.kubernetes.io/name: zpush
+ ports:
+ - name: http
+ port: 80
+ targetPort: http
+---
+# EAS endpoint on mail.dezky.eu. Path-scoped: everything else on the host
+# (CalDAV well-knowns, /dav) keeps routing to Stalwart via the mail-dav
+# Ingress. NO redirect middleware — EAS is POST-heavy and devices don't
+# follow 301s (same reasoning as the autodiscover Ingress). No cert-manager
+# annotation either: the mail-dav Ingress already owns the Certificate that
+# keeps mail-dezky-eu-traefik-tls fresh; we only reference the secret.
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: zpush-eas
+ namespace: dezky-apps
+ annotations:
+ traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
+spec:
+ ingressClassName: traefik
+ tls:
+ - hosts:
+ - mail.dezky.eu
+ secretName: mail-dezky-eu-traefik-tls
+ rules:
+ - host: mail.dezky.eu
+ http:
+ paths:
+ - path: /Microsoft-Server-ActiveSync
+ pathType: Prefix
+ backend:
+ service:
+ name: zpush
+ port:
+ number: 80
diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh
index 73c2373..ca0d9d7 100755
--- a/scripts/bootstrap.sh
+++ b/scripts/bootstrap.sh
@@ -181,6 +181,7 @@ HOSTS_ENTRIES=(
"operator.dezky.local"
"auth.dezky.local"
"mail.dezky.local"
+ "autodiscover.dezky.local"
"files.dezky.local"
"office.dezky.local"
"meet.dezky.local"
diff --git a/services/zpush/Dockerfile b/services/zpush/Dockerfile
new file mode 100644
index 0000000..a13b92b
--- /dev/null
+++ b/services/zpush/Dockerfile
@@ -0,0 +1,76 @@
+# dezky EAS gateway — Z-Push (AGPLv3) wrapping Stalwart in Exchange
+# ActiveSync so "Exchange" accounts on iOS/Android native Mail/Calendar get
+# two-way mail + calendar + contacts sync. Z-Push talks to Stalwart with the
+# client's own Basic credentials (mailbox password or app password) over
+# IMAP/CalDAV/CardDAV — this container stores no secrets, only sync state.
+#
+# Z-Push 2.6.x speaks EAS up to 14.1. That covers native mobile clients but
+# NOT the Outlook mobile app (Microsoft enforces EAS >= 16.1 there since
+# March 2026) and not new Outlook for Windows (no EAS at all). See
+# LICENSE-NOTES.md for the AGPL source-offer obligation.
+
+ARG ZPUSH_VERSION=2.6.4
+
+FROM alpine/git AS source
+ARG ZPUSH_VERSION
+RUN git clone --depth 1 --branch ${ZPUSH_VERSION} \
+ https://github.com/EGroupware/z-push.git /z-push
+
+# php:8.2 — the imap extension lives in PHP core through 8.3 and moved to
+# PECL in 8.4; stay on a version where docker-php-ext-install still works.
+# Pinned to the bookworm base: Debian trixie dropped the (upstream-dead)
+# uw-imap libc-client packages the imap extension compiles against.
+FROM php:8.2-apache-bookworm
+ARG ZPUSH_VERSION
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ libc-client2007e-dev libkrb5-dev libicu-dev \
+ && docker-php-ext-configure imap --with-kerberos --with-imap-ssl \
+ && docker-php-ext-install -j"$(nproc)" imap intl sysvshm sysvsem \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=source /z-push/src/ /usr/share/z-push/
+
+# Main config: keep the 50+ upstream defaults, patch only what we change.
+# The greps make the build fail loudly if an upstream config rename ever
+# makes a sed miss instead of shipping a silently unconfigured gateway.
+# (USE_FULLEMAIL_FOR_LOGIN is already true in the EGroupware fork.)
+RUN sed -i \
+ -e "s|define('TIMEZONE', '');|define('TIMEZONE', 'Europe/Copenhagen');|" \
+ -e "s|define('BACKEND_PROVIDER', '');|define('BACKEND_PROVIDER', 'BackendCombined');|" \
+ /usr/share/z-push/config.php \
+ && grep -q "define('BACKEND_PROVIDER', 'BackendCombined')" /usr/share/z-push/config.php \
+ && grep -q "define('USE_FULLEMAIL_FOR_LOGIN', true)" /usr/share/z-push/config.php
+
+# PHP 8 fix (upstream Z-Push 2.6.4 bug): sem_get()/shm_attach() return
+# objects instead of resources since PHP 8.0 and this debug log line
+# sprintf's them — a fatal TypeError on every request. The rest of the
+# provider only compares the handles against false, which is PHP 8-safe.
+RUN sed -i \
+ 's|sprintf("%s(): Initialized mutexid %s and memid %s.", $class, $this->mutexid, $this->memid)|sprintf("%s(): Initialized shared memory mutex and segment.", $class)|' \
+ /usr/share/z-push/backend/ipcsharedmemory/ipcsharedmemoryprovider.php \
+ && grep -q "Initialized shared memory mutex and segment" \
+ /usr/share/z-push/backend/ipcsharedmemory/ipcsharedmemoryprovider.php
+
+# Backend + autodiscover configs are small files — full replacements.
+COPY config/caldav.config.php /usr/share/z-push/backend/caldav/config.php
+COPY config/carddav.config.php /usr/share/z-push/backend/carddav/config.php
+COPY config/imap.config.php /usr/share/z-push/backend/imap/config.php
+COPY config/combined.config.php /usr/share/z-push/backend/combined/config.php
+COPY config/autodiscover.config.php /usr/share/z-push/autodiscover/config.php
+
+# Schema-sniffing autodiscover dispatcher (mobilesync → Z-Push, outlook
+# schema → proxied to Stalwart). Lives inside autodiscover/ because
+# autodiscover.php resolves its requires relative to that directory.
+COPY autodiscover-router.php /usr/share/z-push/autodiscover/router.php
+
+COPY apache/zpush.conf /etc/apache2/conf-available/zpush.conf
+COPY php/zpush.ini /usr/local/etc/php/conf.d/zpush.ini
+RUN a2enconf zpush \
+ && mkdir -p /var/lib/z-push/state /var/log/z-push \
+ && chown -R www-data:www-data /var/lib/z-push /var/log/z-push \
+ && echo "${ZPUSH_VERSION}" > /usr/share/z-push/DEZKY_PINNED_VERSION
+
+VOLUME /var/lib/z-push
+EXPOSE 80
diff --git a/services/zpush/LICENSE-NOTES.md b/services/zpush/LICENSE-NOTES.md
new file mode 100644
index 0000000..ca0e1c1
--- /dev/null
+++ b/services/zpush/LICENSE-NOTES.md
@@ -0,0 +1,37 @@
+# Z-Push licensing notes (AGPLv3)
+
+This image bundles [Z-Push](https://github.com/EGroupware/z-push) (EGroupware
+fork), licensed under the **GNU Affero General Public License v3**.
+
+## Why this doesn't affect dezky's own code
+
+Z-Push runs as an **isolated network service**. dezky components talk to it
+only over network protocols (HTTPS for EAS clients; Z-Push itself talks to
+Stalwart over IMAP/CalDAV/CardDAV). Nothing links against Z-Push code, so the
+AGPL's copyleft does not extend to the portal, platform-api, or any other
+dezky service.
+
+## What the AGPL obliges us to do
+
+Because users interact with Z-Push over a network, AGPL §13 requires that we
+offer them the **corresponding source of the exact version we run, including
+our modifications**. Our modifications are:
+
+- the pinned upstream version (see `ZPUSH_VERSION` in the `Dockerfile` and
+ `/usr/share/z-push/DEZKY_PINNED_VERSION` in the image)
+- the replaced config files in `config/`
+- the autodiscover dispatcher `autodiscover-router.php`
+- two sed-patched values in the main `config.php` (TIMEZONE,
+ BACKEND_PROVIDER — see `Dockerfile`)
+- a one-line PHP 8 fix in
+ `backend/ipcsharedmemory/ipcsharedmemoryprovider.php` (a debug sprintf of
+ SysV handles that are objects, not resources, since PHP 8.0 — see
+ `Dockerfile`)
+
+Everything lives self-contained in this directory. **Compliance action:** the
+client-setup/help page that documents Exchange account setup must link to
+(a) the upstream tag on GitHub and (b) this directory's contents (or a
+published copy of them). Keep that link in step with `ZPUSH_VERSION` bumps.
+
+Z-Push's own license text ships in the image at `/usr/share/z-push` (see the
+upstream `LICENSE` file in the cloned repository root).
diff --git a/services/zpush/apache/zpush.conf b/services/zpush/apache/zpush.conf
new file mode 100644
index 0000000..f776f37
--- /dev/null
+++ b/services/zpush/apache/zpush.conf
@@ -0,0 +1,21 @@
+# Z-Push EAS endpoints. EAS device ids arrive URL-encoded in the query
+# string and may contain encoded slashes.
+AllowEncodedSlashes On
+
+Alias /Microsoft-Server-ActiveSync /usr/share/z-push/index.php
+
+# All capitalizations clients probe. The router answers mobilesync-schema
+# requests itself and proxies outlook-schema (mail) requests to Stalwart.
+Alias /autodiscover/autodiscover.xml /usr/share/z-push/autodiscover/router.php
+Alias /Autodiscover/Autodiscover.xml /usr/share/z-push/autodiscover/router.php
+Alias /AutoDiscover/AutoDiscover.xml /usr/share/z-push/autodiscover/router.php
+
+
+ Options -Indexes
+ AllowOverride None
+ Require all granted
+
+
+# Expose the raw Authorization header to PHP so the autodiscover proxy can
+# forward it upstream verbatim (mod_php only decodes Basic into PHP_AUTH_*).
+SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
diff --git a/services/zpush/autodiscover-router.php b/services/zpush/autodiscover-router.php
new file mode 100644
index 0000000..80f907c
--- /dev/null
+++ b/services/zpush/autodiscover-router.php
@@ -0,0 +1,60 @@
+ $_SERVER['REQUEST_METHOD'],
+ CURLOPT_POSTFIELDS => $body,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 15,
+));
+$response = curl_exec($ch);
+
+if ($response === false) {
+ http_response_code(502);
+ header('Content-Type: text/plain');
+ echo "autodiscover upstream unreachable\n";
+ curl_close($ch);
+ exit;
+}
+
+$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
+curl_close($ch);
+
+http_response_code($status ?: 502);
+header('Content-Type: ' . ($contentType ?: 'application/xml'));
+echo $response;
diff --git a/services/zpush/config/autodiscover.config.php b/services/zpush/config/autodiscover.config.php
new file mode 100644
index 0000000..c3b1f3a
--- /dev/null
+++ b/services/zpush/config/autodiscover.config.php
@@ -0,0 +1,32 @@
+/Microsoft-Server-ActiveSync.
+define('ZPUSH_HOST', getenv('ZPUSH_HOST') ?: 'mail.dezky.eu');
+
+define('TIMEZONE', 'Europe/Copenhagen');
+define('BASE_PATH', dirname($_SERVER['SCRIPT_FILENAME']) . '/');
+
+// Devices authenticate as the full mail address — matches the main config
+// and what the portal tells users.
+define('USE_FULLEMAIL_FOR_LOGIN', true);
+define('AUTODISCOVER_LOGIN_TYPE', AUTODISCOVER_LOGIN_EMAIL);
+
+// Autodiscover authenticates the requesting user through the same backend
+// stack as sync does (ZPush::GetBackend() reads this constant from THIS
+// file, not from the main config.php).
+define('BACKEND_PROVIDER', 'BackendCombined');
+
+define('LOGBACKEND', 'filelog');
+define('LOGFILEDIR', '/var/log/z-push/');
+define('LOGFILE', LOGFILEDIR . 'autodiscover.log');
+define('LOGERRORFILE', LOGFILEDIR . 'autodiscover-error.log');
+define('LOGLEVEL', LOGLEVEL_INFO);
+define('LOGUSERLEVEL', LOGLEVEL_DEVICEID);
+
+// The logger passes this global straight to SetSpecialLogUsers(array) —
+// it must exist even when no per-user WBXML debugging is wanted.
+$specialLogUsers = array();
diff --git a/services/zpush/config/caldav.config.php b/services/zpush/config/caldav.config.php
new file mode 100644
index 0000000..7e0df2f
--- /dev/null
+++ b/services/zpush/config/caldav.config.php
@@ -0,0 +1,23 @@
+/ where
+// is the Stalwart account name — the LOCAL PART of the mail
+// address (platform-api creates mailboxes with name=localPart, see
+// services/platform-api/src/integrations/stalwart.client.ts). Logins are
+// full emails (USE_FULLEMAIL_FOR_LOGIN), so the path uses %l, Z-Push's
+// local-part placeholder, not %u.
+
+define('CALDAV_PROTOCOL', 'http');
+define('CALDAV_SERVER', getenv('CALDAV_SERVER') ?: 'stalwart');
+define('CALDAV_PORT', getenv('CALDAV_PORT') ?: '8080');
+define('CALDAV_PATH', '/dav/cal/%l/');
+
+// Stalwart auto-creates a calendar named "default" for every account.
+define('CALDAV_PERSONAL', 'default');
+
+// sync-collection REPORT (RFC 6578). Start with the safe full-comparison
+// mode; flip to true once proven against Stalwart's DAV implementation.
+define('CALDAV_SUPPORTS_SYNC', false);
+define('CALDAV_MAX_SYNC_PERIOD', 2147483647);
diff --git a/services/zpush/config/carddav.config.php b/services/zpush/config/carddav.config.php
new file mode 100644
index 0000000..1bce1be
--- /dev/null
+++ b/services/zpush/config/carddav.config.php
@@ -0,0 +1,21 @@
+/ with
+// an auto-created address book named "default".
+
+define('CARDDAV_PROTOCOL', 'http');
+define('CARDDAV_SERVER', getenv('CARDDAV_SERVER') ?: (getenv('CALDAV_SERVER') ?: 'stalwart'));
+define('CARDDAV_PORT', getenv('CARDDAV_PORT') ?: '8080');
+define('CARDDAV_PATH', '/dav/card/%l/');
+define('CARDDAV_DEFAULT_PATH', '/dav/card/%l/default/');
+
+// CARDDAV_GAL_PATH deliberately NOT defined — no global address list in
+// Stalwart; the backend disables GAL search when the constant is absent.
+define('CARDDAV_GAL_MIN_LENGTH', 5);
+
+define('CARDDAV_CONTACTS_FOLDER_NAME', '%u Addressbook');
+// Safe full-comparison mode first — same reasoning as CALDAV_SUPPORTS_SYNC.
+define('CARDDAV_SUPPORTS_SYNC', false);
+define('CARDDAV_SUPPORTS_FN_SEARCH', false);
+define('CARDDAV_URL_VCARD_EXTENSION', '.vcf');
diff --git a/services/zpush/config/combined.config.php b/services/zpush/config/combined.config.php
new file mode 100644
index 0000000..643fce8
--- /dev/null
+++ b/services/zpush/config/combined.config.php
@@ -0,0 +1,44 @@
+ array(
+ 'i' => array('name' => 'BackendIMAP'),
+ 'c' => array('name' => 'BackendCalDAV'),
+ 'd' => array('name' => 'BackendCardDAV'),
+ ),
+ 'delimiter' => '/',
+ 'folderbackend' => array(
+ SYNC_FOLDER_TYPE_INBOX => 'i',
+ SYNC_FOLDER_TYPE_DRAFTS => 'i',
+ SYNC_FOLDER_TYPE_WASTEBASKET => 'i',
+ SYNC_FOLDER_TYPE_SENTMAIL => 'i',
+ SYNC_FOLDER_TYPE_OUTBOX => 'i',
+ SYNC_FOLDER_TYPE_OTHER => 'i',
+ SYNC_FOLDER_TYPE_USER_MAIL => 'i',
+ SYNC_FOLDER_TYPE_APPOINTMENT => 'c',
+ SYNC_FOLDER_TYPE_USER_APPOINTMENT => 'c',
+ SYNC_FOLDER_TYPE_TASK => 'c',
+ SYNC_FOLDER_TYPE_USER_TASK => 'c',
+ SYNC_FOLDER_TYPE_CONTACT => 'd',
+ SYNC_FOLDER_TYPE_USER_CONTACT => 'd',
+ // No notes/journal store in Stalwart — let mail own the rest
+ // so folder creation never lands on a DAV backend by surprise.
+ SYNC_FOLDER_TYPE_NOTE => 'i',
+ SYNC_FOLDER_TYPE_USER_NOTE => 'i',
+ SYNC_FOLDER_TYPE_JOURNAL => 'i',
+ SYNC_FOLDER_TYPE_USER_JOURNAL => 'i',
+ SYNC_FOLDER_TYPE_UNKNOWN => 'i',
+ ),
+ 'rootcreatefolderbackend' => 'i',
+ );
+ }
+}
diff --git a/services/zpush/config/imap.config.php b/services/zpush/config/imap.config.php
new file mode 100644
index 0000000..d52b7b3
--- /dev/null
+++ b/services/zpush/config/imap.config.php
@@ -0,0 +1,48 @@
+ getenv('SMTP_SERVER') ?: (getenv('IMAP_SERVER') ?: 'stalwart'),
+ 'port' => (int) (getenv('SMTP_PORT') ?: 587),
+ 'auth' => true,
+ 'username' => 'imap_username',
+ 'password' => 'imap_password',
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ 'allow_self_signed' => true,
+);
diff --git a/services/zpush/php/zpush.ini b/services/zpush/php/zpush.ini
new file mode 100644
index 0000000..077880a
--- /dev/null
+++ b/services/zpush/php/zpush.ini
@@ -0,0 +1,11 @@
+; Z-Push runtime tuning. EAS Ping requests are long-poll (Z-Push's own
+; SCRIPT_TIMEOUT handles per-command limits) and Sync payloads can carry
+; attachments, hence the raised execution time and body sizes.
+memory_limit = 256M
+max_execution_time = 900
+post_max_size = 32M
+upload_max_filesize = 32M
+log_errors = On
+error_log = /dev/stderr
+display_errors = Off
+expose_php = Off