From a02f5f937d5ed500108173fee1c3ecc2d2428887 Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Sat, 18 Apr 2026 10:56:24 -0400 Subject: [PATCH 1/8] Zipdownload: buffer at most 512KiB during download --- plugins/zipdownload/composer.json | 2 +- plugins/zipdownload/zipdownload.php | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/plugins/zipdownload/composer.json b/plugins/zipdownload/composer.json index 7639225275..f6fe39ca18 100644 --- a/plugins/zipdownload/composer.json +++ b/plugins/zipdownload/composer.json @@ -3,7 +3,7 @@ "type": "roundcube-plugin", "description": "Adds an option to download all attachments to a message in one zip file, when a message has multiple attachments. Also allows the download of a selection of messages in one zip file. Supports mbox and maildir format.", "license": "GPL-3.0-or-later", - "version": "3.6", + "version": "3.8", "authors": [ { "name": "Thomas Bruederli", diff --git a/plugins/zipdownload/zipdownload.php b/plugins/zipdownload/zipdownload.php index d3aa92baed..4c323e6d68 100644 --- a/plugins/zipdownload/zipdownload.php +++ b/plugins/zipdownload/zipdownload.php @@ -369,7 +369,20 @@ private function _deliver_zipfile($tmpfname, $filename) $rcmail->output->download_headers($filename, ['length' => filesize($tmpfname)]); - readfile($tmpfname); + $tmpfp = fopen($tmpfname, 'r'); + if (!$tmpfp) { + return; + } + while (true) { + $data = fread($tmpfp, 512 * 1024); + if (strlen($data) == 0) { + break; + } + echo $data; + ob_flush(); + flush(); + } + fclose($tmpfp); } /** From 1972a9e47645ae5027f8b88cef88f1ab6d365b6d Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Mon, 20 Apr 2026 10:33:41 -0400 Subject: [PATCH 2/8] Avoid compression and stripping of Content-Length * Apache doesn't trust Content-Length headers by default; it will strip them and, only if length is known (on disk/all buffered), add it back * Brotli already avoided compressing files already compressed, do the same with the deflate method * Don't recompress zips created by the zipdownload plugin --- public_html/.htaccess | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/public_html/.htaccess b/public_html/.htaccess index e6d4b02a55..0f50a831a8 100644 --- a/public_html/.htaccess +++ b/public_html/.htaccess @@ -5,15 +5,21 @@ RewriteEngine On RewriteRule ^favicon\.ico$ static.php/skins/elastic/images/favicon.ico +# https://httpd.apache.org/docs/2.4/env.html#cgilike +SetEnv ap_trust_cgilike_cl 1 + SetOutputFilter DEFLATE +# some assets have been compressed, so no need to do it again +SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|web[pm]|woff2?)$ no-gzip +SetEnvIfExpr "%{QUERY_STRING} =~ /(?:^|&)_action=plugin\.zipdownload\.(?:attachments|messages)(?:&|$)/" no-gzip # prefer to brotli over gzip if brotli is available SetOutputFilter BROTLI_COMPRESS -# some assets have been compressed, so no need to do it again SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|web[pm]|woff2?)$ no-brotli +SetEnvIfExpr "%{QUERY_STRING} =~ /(?:^|&)_action=plugin\.zipdownload\.(?:attachments|messages)(?:&|$)/" no-brotli From 1077599cb527a7b032201a1759f78d89a2761f58 Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Sat, 18 Apr 2026 07:46:08 -0400 Subject: [PATCH 3/8] Zipdownload: add mtime to files in maildir exports --- plugins/zipdownload/zipdownload.php | 51 ++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/plugins/zipdownload/zipdownload.php b/plugins/zipdownload/zipdownload.php index 4c323e6d68..3334209100 100644 --- a/plugins/zipdownload/zipdownload.php +++ b/plugins/zipdownload/zipdownload.php @@ -19,6 +19,7 @@ class zipdownload extends rcube_plugin private $charset = 'ASCII'; private $names = []; private $default_limit = '50MB'; + private $timezone; // RFC4155: mbox date format public const MBOX_DATE_FORMAT = 'D M d H:i:s Y'; @@ -251,14 +252,15 @@ private function _download_messages($messageset) foreach ($uids as $uid) { $headers = $imap->get_message_headers($uid); + // Received (internal) date + $date = rcube_utils::anytodatetime($headers->internaldate); + if ($mode == 'mbox') { // Sender address $from = rcube_mime::decode_address_list($headers->from, null, true, $headers->charset, true); $from = array_shift($from); $from = preg_replace('/\s/', '-', $from); - // Received (internal) date - $date = rcube_utils::anytodatetime($headers->internaldate); if ($date) { $date = $date->setTimezone($timezone)->format(self::MBOX_DATE_FORMAT); } else { @@ -277,7 +279,7 @@ private function _download_messages($messageset) $path = $folders ? str_replace($delimiter, '/', $mbox) . '/' : ''; $disp_name = $path . $uid . ($subject ? " {$subject}" : '') . '.eml'; - $messages[$uid . ':' . $mbox] = $disp_name; + $messages[$uid . ':' . $mbox] = ($date ? $date->getTimestamp() : '') . ':' . $disp_name; } $size += $headers->size; @@ -305,6 +307,7 @@ private function _download_messages($messageset) } // open zip file + putenv('TZ=UTC'); // see _datetime_to_ziplocal() comments $zip = new \ZipArchive(); $zip->open($tmpfname, \ZipArchive::OVERWRITE); @@ -331,12 +334,17 @@ private function _download_messages($messageset) fwrite($tmpfp, "\r\n"); } } else { // maildir + [$date, $filename] = explode(':', $value, 2); $tmpfn = rcube_utils::temp_filename('zipmessage'); $fp = fopen($tmpfn, 'w'); $imap->get_raw_body($uid, $fp); $tempfiles[] = $tmpfn; fclose($fp); - $zip->addFile($tmpfn, $value); + $zip->addFile($tmpfn, $filename); + if ($date) { + $date = $this->_datetime_to_ziplocal(new \DateTime('@' . $date)); + $zip->setMtimeName($filename, $date->getTimestamp()); + } } } @@ -360,6 +368,41 @@ private function _download_messages($messageset) exit; } + /** + * Zip files do not store timezones; most extraction tools extract times + * as though they were local times. This converts the UTC times inside + * DateTime objects into a user's preferred local time (despite claiming + * to still be UTC) so that when added and later extracted to/from a zip + * file, they will be in that user's local time. + * + * Also, ZipArchive creation is affected the system's default timezone + * (NOT date_default_timezone_set); to mitigate this, putenv('TZ=UTC'). + * + * @param \DateTimeInterface $real The accurate DateTime of a file (is not changed) + * + * @return \DateTimeInterface A "fake" DateTimeImmutable for inclusion into a zip + */ + private function _datetime_to_ziplocal($real) + { + if (!$this->timezone) { + if ($this->timezone === false) { + return $real; + } + $rcmail = rcmail::get_instance(); + try { + $this->timezone = new \DateTimeZone($rcmail->config->get('timezone')); + } catch (\DateInvalidTimeZoneException) { + $this->timezone = false; + return $real; + } + } + + $real = \DateTime::createFromInterface($real); + $real->setTimezone($this->timezone); + $local = max($real->format('Y/m/d H:i:s'), '1980/01/01 00:00:00'); // Earliest supported by zip + return \DateTimeImmutable::createFromFormat('Y/m/d H:i:s O', $local . ' +0000'); + } + /** * Helper method to send the zip archive to the browser */ From 286c8939cef1e1e15cc49b19a64ea9e4c4092580 Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Mon, 4 May 2026 16:30:07 -0400 Subject: [PATCH 4/8] Zipdownload: Change charset in ex. config to UTF-8 It's already the default if unspecified, and the zip format has supported UTF-8 since 2006, so there's little reason to do otherwise. https://www.loc.gov/preservation/digital/formats/digformatspecs/APPNOTE(20060929)_Version_6.3.0.txt --- plugins/zipdownload/config.inc.php.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/zipdownload/config.inc.php.dist b/plugins/zipdownload/config.inc.php.dist index a6911067fc..98332d5213 100644 --- a/plugins/zipdownload/config.inc.php.dist +++ b/plugins/zipdownload/config.inc.php.dist @@ -16,4 +16,4 @@ $config['zipdownload_attachments'] = 1; $config['zipdownload_selection'] = '50MB'; // Charset to use for filenames inside the zip -$config['zipdownload_charset'] = 'ISO-8859-1'; +$config['zipdownload_charset'] = 'UTF-8'; From 1b55aba29a031d9daad20e8d536c86cbc9fef27b Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Sat, 18 Apr 2026 08:11:29 -0400 Subject: [PATCH 5/8] Zipdownload: factor out zip creation/download --- plugins/zipdownload/zipdownload.php | 97 ++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 28 deletions(-) diff --git a/plugins/zipdownload/zipdownload.php b/plugins/zipdownload/zipdownload.php index 3334209100..0400c745f0 100644 --- a/plugins/zipdownload/zipdownload.php +++ b/plugins/zipdownload/zipdownload.php @@ -132,9 +132,22 @@ public function download_attachments() // require CSRF protected request $rcmail->request_security_check(rcube_utils::INPUT_GET); + $message = new rcube_message(rcube_utils::get_input_string('_uid', rcube_utils::INPUT_GET)); + $filename = ($this->_filename_from_subject($message->subject) ?: 'attachments') . '.zip'; + + $this->_download_attachments_tempfile($message, $filename); + } + + /** + * Perform attachment download using temporary files + * + * @param rcube_message $message Where to retrieve attachments + * @param string $filename Name to give to the download file + */ + public function _download_attachments_tempfile($message, $filename) + { $tmpfname = rcube_utils::temp_filename('zipdownload'); $tempfiles = [$tmpfname]; - $message = new rcube_message(rcube_utils::get_input_string('_uid', rcube_utils::INPUT_GET)); // open zip file $zip = new \ZipArchive(); @@ -154,8 +167,6 @@ public function download_attachments() $zip->close(); - $filename = ($this->_filename_from_subject($message->subject) ?: 'attachments') . '.zip'; - $this->_deliver_zipfile($tmpfname, $filename); // delete temporary files from disk @@ -233,8 +244,6 @@ private function _download_messages($messageset) $limit = $rcmail->config->get('zipdownload_selection', $this->default_limit); $limit = $limit !== true ? parse_bytes($limit) : -1; $delimiter = $imap->get_hierarchy_delimiter(); - $tmpfname = rcube_utils::temp_filename('zipdownload'); - $tempfiles = [$tmpfname]; $folders = count($messageset) > 1; $timezone = new \DateTimeZone('UTC'); $messages = []; @@ -285,8 +294,6 @@ private function _download_messages($messageset) $size += $headers->size; if ($limit > 0 && $size > $limit) { - unlink($tmpfname); - $msg = $this->gettext([ 'name' => 'sizelimiterror', 'vars' => ['$size' => rcmail_action::show_bytes($limit)], @@ -299,11 +306,62 @@ private function _download_messages($messageset) } } + $basename = $folders ? 'messages' : $imap->get_folder(); + $this->_download_messages_tempfile($messages, $mode, $basename); + } + + /** + * Helper method to add a single email to an mbox-style file stream + * + * @param resource $stream File stream to write to + * @param string $header Mbox header to write before the email + * @param string $mbox The mailbox folder containing the email + * @param string $uid The UID of the email to write + * @param bool $is_last Is this the last email in the mbox + */ + private function _write_mbox_stream($stream, $header, $mbox, $uid, $is_last) + { + $rcmail = rcmail::get_instance(); + $imap = $rcmail->get_storage(); + + fwrite($stream, $header); + $imap->set_folder($mbox); + + // Use stream filter to quote "From " in the message body + $filter = stream_filter_append($stream, 'mbox_filter'); + $imap->get_raw_body($uid, $stream); + stream_filter_remove($filter); + + // Make sure the delimiter is a double \r\n + $fstat = fstat($stream); + if (stream_get_contents($stream, 2, $fstat['size'] - 2) != "\r\n") { + fwrite($stream, "\r\n"); + } + if (!$is_last) { + fwrite($stream, "\r\n"); + } + } + + /** + * Perform message download using temporary files + * + * @param array $messages Map of uid:mbox => mbox_header or timestamp:display_name + * @param string $mode The _mode POST parameter + * @param string $basename Name, without extension, to give to the download file + */ + private function _download_messages_tempfile($messages, $mode, $basename) + { + $rcmail = rcmail::get_instance(); + $imap = $rcmail->get_storage(); + $tmpfname = rcube_utils::temp_filename('zipdownload'); + $tempfiles = [$tmpfname]; + if ($mode == 'mbox') { $tmpfp = fopen($tmpfname . '.mbox', 'w'); if (!$tmpfp) { exit; } + stream_filter_register('mbox_filter', 'zipdownload_mbox_filter'); } // open zip file @@ -314,29 +372,14 @@ private function _download_messages($messageset) $last_key = array_key_last($messages); foreach ($messages as $key => $value) { [$uid, $mbox] = explode(':', $key, 2); - $imap->set_folder($mbox); if (!empty($tmpfp)) { - fwrite($tmpfp, $value); - - // Use stream filter to quote "From " in the message body - stream_filter_register('mbox_filter', 'zipdownload_mbox_filter'); - $filter = stream_filter_append($tmpfp, 'mbox_filter'); - $imap->get_raw_body($uid, $tmpfp); - stream_filter_remove($filter); - - // Make sure the delimiter is a double \r\n - $fstat = fstat($tmpfp); - if (stream_get_contents($tmpfp, 2, $fstat['size'] - 2) != "\r\n") { - fwrite($tmpfp, "\r\n"); - } - if ($key != $last_key) { - fwrite($tmpfp, "\r\n"); - } + $this->_write_mbox_stream($tmpfp, $value, $mbox, $uid, $key == $last_key); } else { // maildir [$date, $filename] = explode(':', $value, 2); $tmpfn = rcube_utils::temp_filename('zipmessage'); $fp = fopen($tmpfn, 'w'); + $imap->set_folder($mbox); $imap->get_raw_body($uid, $fp); $tempfiles[] = $tmpfn; fclose($fp); @@ -348,17 +391,15 @@ private function _download_messages($messageset) } } - $filename = $folders ? 'messages' : $imap->get_folder(); - if (!empty($tmpfp)) { $tempfiles[] = $tmpfname . '.mbox'; fclose($tmpfp); - $zip->addFile($tmpfname . '.mbox', $filename . '.mbox'); + $zip->addFile($tmpfname . '.mbox', $basename . '.mbox'); } $zip->close(); - $this->_deliver_zipfile($tmpfname, $filename . '.zip'); + $this->_deliver_zipfile($tmpfname, $basename . '.zip'); // delete temporary files from disk foreach ($tempfiles as $tmpfn) { From ab7d10acafa24646d5eb2f106abfafde9c4a4a73 Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Sat, 18 Apr 2026 10:59:02 -0400 Subject: [PATCH 6/8] Zipdownload: initial ZipStream support Mbox format not yet implemented --- .github/workflows/ci.yml | 1 + composer.json | 3 +- plugins/zipdownload/zipdownload.php | 82 ++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11a8df2a92..81b3bf3dff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,7 @@ jobs: run: | composer require "kolab/net_ldap3:~1.1.1" --no-update composer require "laravel/dusk:^8.3" --no-update + composer require "maennchen/zipstream-php:^3.0" --no-update - name: Install dependencies run: composer install --prefer-dist --no-interaction --no-progress diff --git a/composer.json b/composer.json index 1992af5097..bd2b29a774 100644 --- a/composer.json +++ b/composer.json @@ -68,7 +68,8 @@ }, "suggest": { "bjeavons/zxcvbn-php": "^1.0 required for Zxcvbn password strength driver", - "kolab/net_ldap3": "~1.1.4 required for connecting to LDAP" + "kolab/net_ldap3": "~1.1.4 required for connecting to LDAP", + "maennchen/zipstream-php": "^3.0 optional for larger zip support in zipdownload plugin" }, "repositories": [ { diff --git a/plugins/zipdownload/zipdownload.php b/plugins/zipdownload/zipdownload.php index 0400c745f0..8fe6637f8a 100644 --- a/plugins/zipdownload/zipdownload.php +++ b/plugins/zipdownload/zipdownload.php @@ -1,5 +1,7 @@ _filename_from_subject($message->subject) ?: 'attachments') . '.zip'; - $this->_download_attachments_tempfile($message, $filename); + if (class_exists('ZipStream\ZipStream')) { + $this->_download_attachments_zipstream($message, $filename); + } else { + $this->_download_attachments_tempfile($message, $filename); + } } /** @@ -177,6 +184,33 @@ public function _download_attachments_tempfile($message, $filename) exit; } + /** + * Perform attachment download using ZipStream + * + * @param rcube_message $message Where to retrieve attachments + * @param string $filename Name to give to the download file + */ + public function _download_attachments_zipstream($message, $filename) + { + $rcmail = rcmail::get_instance(); + $rcmail->output->download_headers($filename); + + $zip = new ZipStream( + sendHttpHeaders: false, + defaultDeflateLevel: 1, + flushOutput: true + ); + + foreach ($message->attachments as $part) { + $disp_name = $this->_create_displayname($part); + $zip->addFile($disp_name, $message->get_part_body($part->mime_id)); + } + + $zip->finish(); + + exit; + } + /** * Handler for message download action */ @@ -307,7 +341,11 @@ private function _download_messages($messageset) } $basename = $folders ? 'messages' : $imap->get_folder(); - $this->_download_messages_tempfile($messages, $mode, $basename); + if (class_exists('ZipStream\ZipStream')) { + $this->_download_messages_zipstream($messages, $mode, $basename); + } else { + $this->_download_messages_tempfile($messages, $mode, $basename); + } } /** @@ -409,6 +447,46 @@ private function _download_messages_tempfile($messages, $mode, $basename) exit; } + /** + * Perform message download using ZipStream + * + * @param array $messages Map of uid:mbox => mbox_header or timestamp:display_name + * @param string $mode The _mode POST parameter + * @param string $basename Name, without extension, to give to the download file + */ + private function _download_messages_zipstream($messages, $mode, $basename) + { + $rcmail = rcmail::get_instance(); + $imap = $rcmail->get_storage(); + + $rcmail->output->download_headers($basename . '.zip'); + + $zip = new ZipStream( + sendHttpHeaders: false, + defaultDeflateLevel: 1, + flushOutput: true + ); + + $last_key = array_key_last($messages); + foreach ($messages as $key => $value) { + [$uid, $mbox] = explode(':', $key, 2); + + if ($mode == 'mbox') { + stream_filter_register('mbox_filter', 'zipdownload_mbox_filter'); + // TODO + } else { // maildir + [$date, $filename] = explode(':', $value, 2); + $date = $date ? $this->_datetime_to_ziplocal(new \DateTime('@' . $date)) : null; + $imap->set_folder($mbox); + $zip->addFile($filename, $imap->get_raw_body($uid), lastModificationDateTime: $date); + } + } + + $zip->finish(); + + exit; + } + /** * Zip files do not store timezones; most extraction tools extract times * as though they were local times. This converts the UTC times inside From 435d6f5add0174f6eb01f2e0e7f0fb66cf23beb5 Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Tue, 5 May 2026 09:07:11 -0400 Subject: [PATCH 7/8] Zipdownload: Reduce memory usage Using a custom StreamInterface to transfer emails between $imap->get_* and ZipStream::addFile* in 512 KiB chunks reduces memory usage when larger emails or attachments (34MiB) are present to about a fifth of the version without the custom StreamInterface. This also makes implementing the Mbox format easy. --- plugins/zipdownload/zipdownload.php | 253 ++++++++++++++++++++++-- program/lib/Roundcube/rcube_message.php | 10 +- 2 files changed, 249 insertions(+), 14 deletions(-) diff --git a/plugins/zipdownload/zipdownload.php b/plugins/zipdownload/zipdownload.php index 8fe6637f8a..046c081dc6 100644 --- a/plugins/zipdownload/zipdownload.php +++ b/plugins/zipdownload/zipdownload.php @@ -1,5 +1,7 @@ attachments as $part) { $disp_name = $this->_create_displayname($part); - $zip->addFile($disp_name, $message->get_part_body($part->mime_id)); + $fs = new \FiberStream(static fn ($fp) => $zip->addFileFromStream($disp_name, $fp)); + $message->get_part_body($part->mime_id, false, 0, $fs->get_file()); + $fs->close(); } $zip->finish(); @@ -351,13 +355,14 @@ private function _download_messages($messageset) /** * Helper method to add a single email to an mbox-style file stream * - * @param resource $stream File stream to write to - * @param string $header Mbox header to write before the email - * @param string $mbox The mailbox folder containing the email - * @param string $uid The UID of the email to write - * @param bool $is_last Is this the last email in the mbox + * @param resource $stream File stream to write to + * @param string $header Mbox header to write before the email + * @param string $mbox The mailbox folder containing the email + * @param string $uid The UID of the email to write + * @param bool $is_last Is this the last email in the mbox + * @param \FiberStream $fiberstream FiberStream, if any, associated with the $stream */ - private function _write_mbox_stream($stream, $header, $mbox, $uid, $is_last) + private function _write_mbox_stream($stream, $header, $mbox, $uid, $is_last, $fiberstream = null) { $rcmail = rcmail::get_instance(); $imap = $rcmail->get_storage(); @@ -371,8 +376,12 @@ private function _write_mbox_stream($stream, $header, $mbox, $uid, $is_last) stream_filter_remove($filter); // Make sure the delimiter is a double \r\n - $fstat = fstat($stream); - if (stream_get_contents($stream, 2, $fstat['size'] - 2) != "\r\n") { + if ($fiberstream) { + $last_two = $fiberstream->get_last_two(); // if $stream doesn't support the functions below + } else { + $last_two = stream_get_contents($stream, 2, fstat($stream)['size'] - 2); + } + if ($last_two != "\r\n") { fwrite($stream, "\r\n"); } if (!$is_last) { @@ -467,21 +476,30 @@ private function _download_messages_zipstream($messages, $mode, $basename) flushOutput: true ); + if ($mode == 'mbox') { + $fs = new \FiberStream(static fn ($fp) => $zip->addFileFromStream($basename . '.mbox', $fp)); + stream_filter_register('mbox_filter', 'zipdownload_mbox_filter'); + } + $last_key = array_key_last($messages); foreach ($messages as $key => $value) { [$uid, $mbox] = explode(':', $key, 2); if ($mode == 'mbox') { - stream_filter_register('mbox_filter', 'zipdownload_mbox_filter'); - // TODO + $this->_write_mbox_stream($fs->get_file(), $value, $mbox, $uid, $key == $last_key, $fs); } else { // maildir [$date, $filename] = explode(':', $value, 2); $date = $date ? $this->_datetime_to_ziplocal(new \DateTime('@' . $date)) : null; $imap->set_folder($mbox); - $zip->addFile($filename, $imap->get_raw_body($uid), lastModificationDateTime: $date); + $fs = new \FiberStream(static fn ($fp) => $zip->addFileFromStream($filename, $fp, lastModificationDateTime: $date)); + $imap->get_raw_body($uid, $fs->get_file()); + $fs->close(); } } + if ($mode == 'mbox') { + $fs->close(); + } $zip->finish(); exit; @@ -588,3 +606,214 @@ public function filter($in, $out, &$consumed, $closing) return \PSFS_PASS_ON; } } + +/** + * Consider a source of data, which writes to a standard file stream, and + * a destination of data, which reads from that stream, via this pattern: + * $stream = fopen(..., 'w+'); + * write_all_to_stream($stream); + * rewind($stream); + * read_entire_stream($stream); + * The stream must be backed by something, typically a temp file or a + * php://memory file. As an alternative, a FiberStream can be used: + * $fs = new FiberStream('read_entire_stream'); // arg is a callable + * write_all_to_stream($fs->get_file()); + * $fs->close(); + * The FiberStream allows the source to fwrite() up to a certain limit (the + * chunk_size, defaulting to 512 KiB), and then transfers control to the + * destination, which will fread() until the buffer is emptied, after which + * it will transfer control back to the source to begin writing again. By + * using a Fiber to switch back and forth, only a limited amount of buffer + * space is required, typically around 1 to 2 times the chunk_size. + */ +final class FiberStream implements StreamInterface +{ + public const DEFAULT_CHUNK_SIZE = 512 * 1024; + private $chunk_size; + private $dest_fiber; + private $write_file; + private $read_file; + private $buffer = ''; + private $read_pos = 0; + private $last_two = ''; + private $closing = false; + private $closed = false; + + /** + * @param callable(resource): mixed $dest Called by FiberStream to read an entire file stream + * @param int $chunk_size The stream's chunk size and desired buffer limit + */ + public function __construct(callable $dest, $chunk_size = self::DEFAULT_CHUNK_SIZE) + { + $this->chunk_size = $chunk_size; + $this->dest_fiber = new \Fiber(function () use ($dest) { + $this->read_file = StreamWrapper::getResource($this); + stream_set_chunk_size($this->read_file, $this->chunk_size); + $dest($this->read_file); + }); + $this->write_file = StreamWrapper::getResource($this); + stream_set_chunk_size($this->write_file, $chunk_size); + } + + /** + * @return resource A file stream an entire file should be written to + */ + public function get_file() + { + return $this->write_file; + } + + /** + * Start or resume the destination fiber + */ + private function run_dest_fiber() + { + if (!$this->dest_fiber->isStarted()) { + $this->dest_fiber->start(); + } else { + $this->dest_fiber->resume(); + } + } + + private function check_not_closed() + { + if ($this->closed || $this->dest_fiber->isTerminated()) { + throw new \RuntimeException('FiberStream is closed'); + } + } + + #[\Override] + public function write($string): int + { + $this->check_not_closed(); + $this->buffer .= $string; + $last = substr($this->buffer, -2); + if (strlen($last) == 2) { + $this->last_two = $last; + } elseif (strlen($last) == 1) { + $this->last_two = substr($this->last_two, -1) . $last; + } + if (strlen($this->buffer) >= $this->chunk_size) { + $this->run_dest_fiber(); // suspend writer/source and allow dest to read() the buffer + } + return strlen($string); + } + + public function get_last_two() + { + return $this->last_two; // for _write_mbox_stream() + } + + #[\Override] + public function read($length): string + { + $this->check_not_closed(); + if (\Fiber::getCurrent() !== $this->dest_fiber) { + throw new \RuntimeException('Only the dest callable may call FiberStream::read'); + } + if (strlen($this->buffer) == 0 && !$this->closing) { + $this->dest_fiber->suspend(); // suspend reader/dest and allow source to write() more + } + $result = substr($this->buffer, $this->read_pos, $length); + $this->read_pos += $length; + if ($this->read_pos >= strlen($this->buffer)) { + $this->buffer = ''; + $this->read_pos = 0; + } + return $result; + } + + #[\Override] + public function eof(): bool + { + return $this->closing && $this->read_pos >= strlen($this->buffer); + } + + /** + * Do not call fclose() on any files received from a FiberStream (e.g. from get_file()), + * instead call this which will close the files in the correct order. + */ + #[\Override] + public function close(): void + { + if (!$this->closed) { + fclose($this->write_file); // Can cause more calls to our write()/read()/eof(), + $this->closing = true; // so wait until those calls have completed and only then set this flag. + $this->check_not_closed(); // Ensure dest_fiber hasn't returned early; + $this->run_dest_fiber(); // runs until dest returns, it's expected to read() until eof. + fclose($this->read_file); + $this->buffer = ''; + $this->closed = true; + } + } + + #[\Override] + public function __toString(): string + { + return $this->getContents(); + } + + #[\Override] + public function getContents(): string + { + $buffer = $this->buffer; + $this->buffer = ''; + $this->read_pos = 0; + return $buffer; + } + + #[\Override] + public function detach() + { + $this->close(); + return null; + } + + #[\Override] + public function getSize(): ?int + { + return null; + } + + #[\Override] + public function isReadable(): bool + { + return \Fiber::getCurrent() === $this->dest_fiber; + } + + #[\Override] + public function isWritable(): bool + { + return \Fiber::getCurrent() !== $this->dest_fiber; + } + + #[\Override] + public function isSeekable(): bool + { + return false; + } + + #[\Override] + public function rewind(): void + { + $this->seek(0); + } + + #[\Override] + public function seek($offset, $whence = \SEEK_SET): void + { + throw new \RuntimeException('Cannot seek a FiberStream'); + } + + #[\Override] + public function tell(): int + { + throw new \RuntimeException('Cannot determine the position of a FiberStream'); + } + + #[\Override] + public function getMetadata($key = null) + { + return $key ? null : []; + } +} diff --git a/program/lib/Roundcube/rcube_message.php b/program/lib/Roundcube/rcube_message.php index 83f753c3ef..d756969db8 100644 --- a/program/lib/Roundcube/rcube_message.php +++ b/program/lib/Roundcube/rcube_message.php @@ -285,7 +285,10 @@ public function get_part_body($mime_id, $formatted = false, $max_bytes = 0, $mod if (is_resource($mode)) { fwrite($mode, $body); - @rewind($mode); + $stat = fstat($mode); + if ($stat && $stat['size']) { // try to check if this file is seekable + @rewind($mode); + } return true; } @@ -305,7 +308,10 @@ public function get_part_body($mime_id, $formatted = false, $max_bytes = 0, $mod !($mode && $formatted), $max_bytes, $mode && $formatted); if (is_resource($mode)) { - @rewind($mode); + $stat = fstat($mode); + if ($stat && $stat['size']) { // try to check if this file is seekable + @rewind($mode); + } return $body !== false; } From 810855a0968797ef4abc286c98aff709115ea6cd Mon Sep 17 00:00:00 2001 From: Christopher Gurnee Date: Mon, 11 May 2026 19:36:53 -0400 Subject: [PATCH 8/8] Zipdownload: run E2E tests with and w/o ZipStream --- .ci/run_browser_tests.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.ci/run_browser_tests.sh b/.ci/run_browser_tests.sh index 3ce5bb9e5a..68265eba1d 100755 --- a/.ci/run_browser_tests.sh +++ b/.ci/run_browser_tests.sh @@ -63,3 +63,7 @@ TESTS_MODE=tablet vendor/bin/phpunit -c tests/Browser/phpunit.xml --fail-on-warn # Mobile mode tests are unreliable on Github Actions # echo "TESTS_MODE: PHONE" # TESTS_MODE=phone vendor/bin/phpunit -c tests/Browser/phpunit.xml --fail-on-warning --fail-on-risky --display-deprecations --exclude-group=failsonga-phone + +echo "TESTS_MODE: DESKTOP with zipstream" +composer require $COMPOSER_ARGS -n 'maennchen/zipstream-php:^3.0' +TESTS_MODE=desktop vendor/bin/phpunit -c tests/Browser/phpunit.xml --fail-on-warning --fail-on-risky --display-deprecations plugins/zipdownload/tests/Browser