diff --git a/doc/clients.md b/doc/clients.md index 41a94592..a8b12009 100644 --- a/doc/clients.md +++ b/doc/clients.md @@ -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 @@ -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. diff --git a/lib/Horde/ActiveSync.php b/lib/Horde/ActiveSync.php index defd2f4f..5c6cdb96 100644 --- a/lib/Horde/ActiveSync.php +++ b/lib/Horde/ActiveSync.php @@ -1357,4 +1357,104 @@ public static function getTruncSize($truncation) } } + /** + * 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 + * + * @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 + * + * @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 + * + * @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 + ); + } + } + } diff --git a/lib/Horde/ActiveSync/Request/Sync.php b/lib/Horde/ActiveSync/Request/Sync.php index 787fec7a..68227db4 100644 --- a/lib/Horde/ActiveSync/Request/Sync.php +++ b/lib/Horde/ActiveSync/Request/Sync.php @@ -431,6 +431,13 @@ protected function _handle() $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 @@ -1232,7 +1239,6 @@ protected function _resolveOrphanInstanceIdRemoves(array &$collection) )); } - /** * Import client-sent Sync commands that were queued during request * parsing (streaming only). @@ -1314,6 +1320,7 @@ protected function _runDeferredSyncCommands(array &$collection) $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'])) { @@ -2069,6 +2076,12 @@ public function _parseSyncOptions(&$collection) return; } + // Optional server override for BodyPreference / MIMETruncation (0 = off). + Horde_ActiveSync::applyForcedTruncationSize( + $options, + Horde_ActiveSync::getForcedTruncationSize($this->_driver->getSyncConfig()) + ); + $collection = array_merge($collection, $options); } diff --git a/test/unit/Horde/ActiveSync/TruncationCapTest.php b/test/unit/Horde/ActiveSync/TruncationCapTest.php new file mode 100644 index 00000000..e5c060e9 --- /dev/null +++ b/test/unit/Horde/ActiveSync/TruncationCapTest.php @@ -0,0 +1,95 @@ + + * @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']); + } +}