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
+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;