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:
Ronni Baslund
2026-06-12 11:12:11 +02:00
parent 2e3c0f9188
commit 58a2c8077d
16 changed files with 658 additions and 13 deletions
@@ -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