feat(mail): Z-Push Exchange ActiveSync gateway for mobile clients
Wraps Stalwart in EAS so iOS/Android native Mail/Calendar 'Exchange' accounts get two-way mail+calendar+contacts sync (BackendCombined: IMAP + CalDAV /dav/cal/%l/ + CardDAV, credentials pass through). - services/zpush: Z-Push 2.6.4 (AGPLv3, see LICENSE-NOTES.md) on php:8.2-apache-bookworm (trixie dropped libc-client); PHP 8 sysv sprintf fatal sed-patched; autodiscover dispatcher answers mobilesync schema, proxies outlook schema to Stalwart unchanged - prod: zpush Deployment (replicas:1, Recreate — file sync state), /Microsoft-Server-ActiveSync Ingress on mail.dezky.eu (no redirect, POST-heavy), autodiscover.dezky.eu repointed to the dispatcher, selectorless stalwart-imaps/-smtps Services (host-Stalwart is implicit-TLS only: 993/465, no plain 143/587 — verified on node1) - CI: build+deploy zpush like the other apps EAS tops out at 14.1: covers native mobile clients, NOT the Outlook mobile app (needs 16.1) and not new Outlook for Windows (no EAS).
This commit is contained in:
+28
-6
@@ -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"
|
||||
|
||||
@@ -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
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -8,6 +8,7 @@ resources:
|
||||
- namespace.yaml
|
||||
- redirect-middleware.yaml
|
||||
- mail-autodiscovery.yaml
|
||||
- zpush.yaml
|
||||
- platform-api-config.yaml
|
||||
- platform-api.yaml
|
||||
- portal.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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
<Directory /usr/share/z-push>
|
||||
Options -Indexes
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// dezky autodiscover dispatcher. EAS devices and Outlook's mail setup POST
|
||||
// the same /autodiscover/autodiscover.xml URL but request different response
|
||||
// schemas — and Z-Push's autodiscover only answers the mobilesync schema
|
||||
// (it 400s the outlook schema), while Stalwart only serves the mail one.
|
||||
// So: sniff the request body and route mobilesync to Z-Push, everything
|
||||
// else to Stalwart unchanged. Deployed into /usr/share/z-push/autodiscover/
|
||||
// because autodiscover.php resolves its requires relative to that directory.
|
||||
|
||||
$body = file_get_contents('php://input');
|
||||
|
||||
if (stripos($body, 'autodiscover/mobilesync/responseschema') !== false) {
|
||||
chdir(__DIR__);
|
||||
require __DIR__ . '/autodiscover.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$upstream = getenv('MAIL_AUTODISCOVER_UPSTREAM');
|
||||
if ($upstream === false || $upstream === '') {
|
||||
http_response_code(502);
|
||||
header('Content-Type: text/plain');
|
||||
echo "autodiscover upstream not configured\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Forward the raw Authorization header when present (SetEnvIf in zpush.conf
|
||||
// exposes it); fall back to rebuilding Basic from mod_php's decoded pair.
|
||||
$headers = array('Content-Type: ' . ($_SERVER['CONTENT_TYPE'] ?? 'text/xml'));
|
||||
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
$headers[] = 'Authorization: ' . $_SERVER['HTTP_AUTHORIZATION'];
|
||||
} elseif (isset($_SERVER['PHP_AUTH_USER'])) {
|
||||
$headers[] = 'Authorization: Basic '
|
||||
. base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . ($_SERVER['PHP_AUTH_PW'] ?? ''));
|
||||
}
|
||||
|
||||
$ch = curl_init(rtrim($upstream, '/') . '/autodiscover/autodiscover.xml');
|
||||
curl_setopt_array($ch, array(
|
||||
CURLOPT_CUSTOMREQUEST => $_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;
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// dezky replacement for Z-Push's autodiscover/config.php (applied at image
|
||||
// build, see Dockerfile). Only mobilesync-schema requests reach this code —
|
||||
// router.php proxies outlook-schema (mail) autodiscover to Stalwart.
|
||||
|
||||
// Public hostname EAS devices should be pointed at. The ActiveSync URL in
|
||||
// the response becomes https://<ZPUSH_HOST>/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();
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// dezky replacement for Z-Push's backend/caldav/config.php (applied at
|
||||
// image build, see Dockerfile).
|
||||
//
|
||||
// Stalwart serves per-account calendars at /dav/cal/<account>/ where
|
||||
// <account> 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);
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
// dezky replacement for Z-Push's backend/carddav/config.php (applied at
|
||||
// image build, see Dockerfile). Same %l/local-part reasoning as
|
||||
// caldav.config.php; Stalwart's CardDAV root is /dav/card/<account>/ 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');
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// dezky replacement for Z-Push's backend/combined/config.php (applied at
|
||||
// image build, see Dockerfile). One EAS account fans out to three Stalwart
|
||||
// protocols: mail over IMAP, calendar/tasks over CalDAV, contacts over
|
||||
// CardDAV. Login succeeds only if every backend authenticates — they all
|
||||
// hit the same Stalwart account with the same credentials, so that's one
|
||||
// effective check.
|
||||
|
||||
class BackendCombinedConfig {
|
||||
|
||||
public static function GetBackendCombinedConfig() {
|
||||
return array(
|
||||
'backends' => 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// dezky replacement for Z-Push's backend/imap/config.php (applied at image
|
||||
// build, see Dockerfile). Talks to Stalwart over the internal network —
|
||||
// plaintext IMAP/submission on the container network is fine, TLS
|
||||
// terminates at Traefik for the public endpoints.
|
||||
|
||||
define('IMAP_SERVER', getenv('IMAP_SERVER') ?: 'stalwart');
|
||||
define('IMAP_PORT', (int) (getenv('IMAP_PORT') ?: 143));
|
||||
// Dev talks plain IMAP on the docker network; prod host-Stalwart only
|
||||
// exposes IMAPS :993, so zpush.yaml sets '/ssl/novalidate-cert' (the cert
|
||||
// is for mail.dezky.eu, we connect via the cluster service name).
|
||||
define('IMAP_OPTIONS', getenv('IMAP_OPTIONS') ?: '/notls/norsh');
|
||||
define('IMAP_AUTOSEEN_ON_DELETE', false);
|
||||
|
||||
// Stalwart's auto-created special-use folders. Configured explicitly so
|
||||
// Z-Push doesn't guess from localized names.
|
||||
define('IMAP_FOLDER_CONFIGURED', true);
|
||||
define('IMAP_FOLDER_PREFIX', '');
|
||||
define('IMAP_FOLDER_PREFIX_IN_INBOX', false);
|
||||
define('IMAP_FOLDER_INBOX', 'INBOX');
|
||||
define('IMAP_FOLDER_SENT', 'Sent Items');
|
||||
define('IMAP_FOLDER_DRAFT', 'Drafts');
|
||||
define('IMAP_FOLDER_TRASH', 'Deleted Items');
|
||||
define('IMAP_FOLDER_SPAM', 'Junk Mail');
|
||||
define('IMAP_FOLDER_ARCHIVE', 'Archive');
|
||||
|
||||
define('IMAP_INLINE_FORWARD', true);
|
||||
define('IMAP_EXCLUDED_FOLDERS', '');
|
||||
// From-address comes from the authenticated login (full email).
|
||||
define('IMAP_DEFAULTFROM', '');
|
||||
|
||||
// Outgoing mail: authenticated submission to Stalwart as the device's own
|
||||
// user — the same Basic credentials the EAS client supplied. Prod uses
|
||||
// implicit TLS (SMTP_SERVER gets an ssl:// prefix, port 465 — host-Stalwart
|
||||
// has no plain :587); the verify flags are off because this is node-internal
|
||||
// traffic against a cert issued for the public hostname.
|
||||
define('IMAP_SMTP_METHOD', 'smtp');
|
||||
global $imap_smtp_params;
|
||||
$imap_smtp_params = array(
|
||||
'host' => 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,
|
||||
);
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user