Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions doc/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ and a `BodyPreference` **without** `TruncationSize` (full body). Horde
responds with the complete body and no `Truncated` flag.

**Implication:** Nine already limits Sync payload itself and can repair
truncation on demand. A server-side maximum truncation cap is unnecessary for
Nine; if one were applied, Nine would still recover via the button (provided
ItemOperations Fetch remains uncapped).
truncation on demand. A server-side forced Sync truncation size is
unnecessary for Nine when left at 0; when set, Nine can still recover via
ItemOperations Fetch (which remains uncapped).

## iOS Mail

Expand Down Expand Up @@ -110,7 +110,9 @@ rather than a single byte count.

- Prefer leaving Sync body size to the client unless you have a measured
bandwidth reason and understand which devices you serve.
- Capping Sync truncation helps bandwidth only for clients that request large
bodies; it permanently harms Gmail users; Nine and iOS can recover.
- Forcing a low Sync truncation size saves bandwidth for clients that
re-fetch (Nine, iOS); forcing a high value can reduce permanent clipping
on Gmail, which never re-fetches. See forcetruncationsize in Horde
configuration.
- Full-body-on-demand paths (ItemOperations mailbox Fetch) should stay
uncapped so well-behaved clients can complete truncated messages.
100 changes: 100 additions & 0 deletions lib/Horde/ActiveSync.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php

/**
Expand Down Expand Up @@ -789,7 +789,7 @@

// Autodiscovery handles authentication on it's own.
if ($cmd == 'Autodiscover') {
$request = new Horde_ActiveSync_Request_Autodiscover($this, new Horde_ActiveSync_Device($this->_state));

Check failure on line 792 in lib/Horde/ActiveSync.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Class Horde_ActiveSync_Request_Autodiscover constructor invoked with 2 parameters, 1 required. [arguments.count]

if (!empty(self::$_logger)) {
$request->setLogger(self::$_logger);
Expand Down Expand Up @@ -1127,7 +1127,7 @@
*/
public function getSupportedCommands()
{
switch ($this->_maxVersion) {

Check failure on line 1130 in lib/Horde/ActiveSync.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Method Horde_ActiveSync::getSupportedCommands() should return string but return statement is missing. [return.missing]
case self::VERSION_TWOFIVE:
return 'Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,ResolveRecipients,ValidateCert,Provision,Search,Ping';

Expand Down Expand Up @@ -1357,4 +1357,104 @@
}
}

/**
* Resolve activesync/sync forcetruncationsize from sync config.
*
* Falls back to deprecated maximumtruncationsize when the renamed key
* is not present (horde/base#143 installs).
*
* @author Torben Dannhauer <torben@dannhauer.de>
*
* @param array $syncConfig activesync/sync settings from Horde config.
*
* @return integer Forced TruncationSize in bytes; 0 = honor client.
*/
public static function getForcedTruncationSize(array $syncConfig)
{
if (array_key_exists('forcetruncationsize', $syncConfig)) {
return (int)$syncConfig['forcetruncationsize'];
}

return (int)($syncConfig['maximumtruncationsize'] ?? 0);
}

/**
* Apply a server-forced truncation size, overriding the client value.
*
* When $forced is positive, the client-requested size is replaced
* whether it is larger or smaller. A value of 0 (or less) leaves the
* client value unchanged.
*
* @author Torben Dannhauer <torben@dannhauer.de>
*
* @param integer|false|null $size Client size in bytes, or false/null
* for unlimited / no truncation.
* @param integer $forced Server override in bytes; 0 = off.
*
* @return integer|false|null The effective truncation size.
*/
public static function forceTruncationSize($size, $forced)
{
$forced = (int)$forced;
if ($forced <= 0) {
return $size;
}

return $forced;
}

/**
* Apply activesync/sync/forcetruncationsize to collection/options data.
*
* Mutates bodyprefs, bodypartprefs, truncation, and mimetruncation in
* place when a positive forced size is configured. ItemOperations Fetch
* is not affected.
*
* @author Torben Dannhauer <torben@dannhauer.de>
*
* @param array $options Sync/collection options (by reference).
* @param integer $forced Forced TruncationSize in bytes; 0 = honor client.
*/
public static function applyForcedTruncationSize(array &$options, $forced)
{
$forced = (int)$forced;
if ($forced <= 0) {
return;
}

if (!empty($options['bodyprefs']) && is_array($options['bodyprefs'])) {
foreach ($options['bodyprefs'] as &$pref) {
if (!is_array($pref)) {
continue;
}
$pref['truncationsize'] = self::forceTruncationSize(
$pref['truncationsize'] ?? 0,
$forced
);
}
unset($pref);
}

if (!empty($options['bodypartprefs']) && is_array($options['bodypartprefs'])) {
$options['bodypartprefs']['truncationsize'] = self::forceTruncationSize(
$options['bodypartprefs']['truncationsize'] ?? 0,
$forced
);
}

if (array_key_exists('mimetruncation', $options)) {
$options['mimetruncation'] = self::forceTruncationSize(
$options['mimetruncation'],
$forced
);
}

if (array_key_exists('truncation', $options)) {
$options['truncation'] = self::forceTruncationSize(
$options['truncation'],
$forced
);
}
}

}
15 changes: 14 additions & 1 deletion lib/Horde/ActiveSync/Request/Sync.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php

/**
Expand Down Expand Up @@ -431,6 +431,13 @@
$changecount = 0;
$forceChanges = false;

// Force Sync body truncation only (not ItemOperations Fetch).
// Covers partial Sync where Options were restored from SyncCache.
Horde_ActiveSync::applyForcedTruncationSize(
$collection,
Horde_ActiveSync::getForcedTruncationSize($syncSettings)
);

if ($over_window || $cnt_global > $this->_collections->getDefaultWindowSize()) {
// Client-sent commands must still be imported (matching the
// non-streaming flow, where imports happen during parsing
Expand Down Expand Up @@ -1232,7 +1239,6 @@
));
}


/**
* Import client-sent Sync commands that were queued during request
* parsing (streaming only).
Expand Down Expand Up @@ -1314,6 +1320,7 @@
$count += count($deferred['removes']);
$keepAlives += $this->_emitKeepAlive();
}

// Orphans may have been queued without a ServerEntryId; resolve them
// against a single successful co-batched Add before importing.
if (!empty($deferred['orphan_instanceid_removes'])) {
Expand Down Expand Up @@ -2069,6 +2076,12 @@
return;
}

// Optional server override for BodyPreference / MIMETruncation (0 = off).
Horde_ActiveSync::applyForcedTruncationSize(
$options,
Horde_ActiveSync::getForcedTruncationSize($this->_driver->getSyncConfig())
);

$collection = array_merge($collection, $options);
}

Expand Down
95 changes: 95 additions & 0 deletions test/unit/Horde/ActiveSync/TruncationCapTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* @author Torben Dannhauer <torben@dannhauer.de>
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package ActiveSync
*/

namespace Horde\ActiveSync;

use Horde_ActiveSync;
use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\TestCase;

#[CoversNothing]
class TruncationCapTest extends TestCase
{
public function testForceDisabledLeavesClientValue()
{
$this->assertSame(200000, Horde_ActiveSync::forceTruncationSize(200000, 0));
$this->assertSame(false, Horde_ActiveSync::forceTruncationSize(false, 0));
$this->assertSame(500, Horde_ActiveSync::forceTruncationSize(500, 0));
}

public function testForceOverridesLargerClientBodyPreference()
{
$this->assertSame(5000, Horde_ActiveSync::forceTruncationSize(200000, 5000));
}

public function testForceOverridesSmallerClientBodyPreference()
{
$this->assertSame(5000, Horde_ActiveSync::forceTruncationSize(500, 5000));
}

public function testForceOverridesUnlimitedClientBodyPreference()
{
$this->assertSame(5000, Horde_ActiveSync::forceTruncationSize(0, 5000));
$this->assertSame(5000, Horde_ActiveSync::forceTruncationSize(null, 5000));
$this->assertSame(5000, Horde_ActiveSync::forceTruncationSize(false, 5000));
}

public function testGetForcedTruncationSizePrefersNewKey()
{
$this->assertSame(
5000,
Horde_ActiveSync::getForcedTruncationSize([
'forcetruncationsize' => 5000,
'maximumtruncationsize' => 500,
])
);
}

public function testGetForcedTruncationSizeFallsBackToDeprecatedKey()
{
$this->assertSame(
500,
Horde_ActiveSync::getForcedTruncationSize([
'maximumtruncationsize' => 500,
])
);
}

public function testApplyForcedTruncationSizeToOptions()
{
$options = [
'bodyprefs' => [
Horde_ActiveSync::BODYPREF_TYPE_HTML => [
'type' => Horde_ActiveSync::BODYPREF_TYPE_HTML,
'truncationsize' => 200000,
],
Horde_ActiveSync::BODYPREF_TYPE_PLAIN => [
'type' => Horde_ActiveSync::BODYPREF_TYPE_PLAIN,
'truncationsize' => 500,
],
],
'mimetruncation' => false,
'truncation' => 51200,
];

Horde_ActiveSync::applyForcedTruncationSize($options, 0);
$this->assertSame(200000, $options['bodyprefs'][Horde_ActiveSync::BODYPREF_TYPE_HTML]['truncationsize']);
$this->assertSame(500, $options['bodyprefs'][Horde_ActiveSync::BODYPREF_TYPE_PLAIN]['truncationsize']);
$this->assertSame(false, $options['mimetruncation']);

Horde_ActiveSync::applyForcedTruncationSize($options, 5000);
$this->assertSame(5000, $options['bodyprefs'][Horde_ActiveSync::BODYPREF_TYPE_HTML]['truncationsize']);
$this->assertSame(5000, $options['bodyprefs'][Horde_ActiveSync::BODYPREF_TYPE_PLAIN]['truncationsize']);
$this->assertSame(5000, $options['mimetruncation']);
$this->assertSame(5000, $options['truncation']);
}
}
Loading