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
+76
View File
@@ -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
+37
View File
@@ -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).
+21
View File
@@ -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
+60
View File
@@ -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();
+23
View File
@@ -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);
+21
View File
@@ -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');
+44
View File
@@ -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',
);
}
}
+48
View File
@@ -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,
);
+11
View File
@@ -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