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
2 changes: 1 addition & 1 deletion plugins/zipdownload/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/zipdownload/config.inc.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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';
66 changes: 61 additions & 5 deletions plugins/zipdownload/zipdownload.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -305,6 +307,7 @@ private function _download_messages($messageset)
}

// open zip file
putenv('TZ=UTC'); // see _datetime_to_ziplocal() comments

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this still be needed if we use setMtimeName() on every file?

@gurnec gurnec Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately yes. libzip expects times in UTC and always converts them to a local time unless zip_file_set_dostime() is used which ZipArchive doesn't expose. libzip uses localtime() to do the conversion which can be controlled by the TZ environment variable on POSIX and Windows. Different libcs have different ways of setting a default, for example glibc uses /etc/localtime (man tzset). Overriding the default to UTC was the best way I could find to convert these localtime() calls to a no-op.

(Note that I've only tested this on glibc and Windows.)

$zip = new \ZipArchive();
$zip->open($tmpfname, \ZipArchive::OVERWRITE);

Expand All @@ -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());
}
}
}

Expand All @@ -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
*/
Expand All @@ -369,7 +412,20 @@ private function _deliver_zipfile($tmpfname, $filename)

$rcmail->output->download_headers($filename, ['length' => filesize($tmpfname)]);

readfile($tmpfname);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is not readfile(), but output buffering. Can't we just disable output buffering before calling readfile()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a very slight preference to use fread() over readfile() with large files because the latter uses mmap() which means:

  • pages mapped by the kernel don't count against PHP's memory_limit
  • memory usage is difficult to predict, and can change between kernel versions
  • PHP doesn't use madvise(..., MADV_SEQUENTIAL) which would make the situation better

In practice, mmap() is not some new unoptimized kernel feature, and I doubt its use would cause problems, so I'm happy to move back to readfile() if you'd prefer, just let me know.

$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);
}

/**
Expand Down
8 changes: 7 additions & 1 deletion public_html/.htaccess
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@ RewriteEngine On
RewriteRule ^favicon\.ico$ static.php/skins/elastic/images/favicon.ico
</IfModule>

# https://httpd.apache.org/docs/2.4/env.html#cgilike
SetEnv ap_trust_cgilike_cl 1

<IfModule mod_deflate.c>
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
</IfModule>

# prefer to brotli over gzip if brotli is available
<IfModule mod_brotli.c>
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
</IfModule>

<IfModule mod_expires.c>
Expand Down
Loading