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
+28 -6
View File
@@ -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"