Files
roundcubemail/program/actions/mail/search.php

457 lines
16 KiB
PHP

<?php
/*
+-----------------------------------------------------------------------+
| This file is part of the Roundcube Webmail client |
| |
| Copyright (C) The Roundcube Dev Team |
| |
| Licensed under the GNU General Public License version 3 or |
| any later version with exceptions for skins & plugins. |
| See the README file for a full license statement. |
| |
| PURPOSE: |
| Mail messages search action |
+-----------------------------------------------------------------------+
| Author: Benjamin Smith <defitro@gmail.com> |
| Author: Thomas Bruederli <roundcube@gmail.com> |
+-----------------------------------------------------------------------+
*/
class rcmail_action_mail_search extends rcmail_action_mail_index
{
protected static $mode = self::MODE_AJAX;
/**
* Request handler.
*
* @param array $args Arguments from the previous step(s)
*/
#[Override]
public function run($args = [])
{
$rcmail = rcmail::get_instance();
@set_time_limit(170); // extend default max_execution_time to ~3 minutes
// reset list_page and old search results
$rcmail->storage->set_page(1);
$rcmail->storage->set_search_set(null);
$_SESSION['page'] = 1;
// get search string
$str = trim(rcube_utils::get_input_string('_q', rcube_utils::INPUT_GET, true));
$mbox = trim(rcube_utils::get_input_string('_mbox', rcube_utils::INPUT_GET, true));
$filter = trim(rcube_utils::get_input_string('_filter', rcube_utils::INPUT_GET));
$headers = trim(rcube_utils::get_input_string('_headers', rcube_utils::INPUT_GET));
$scope = trim(rcube_utils::get_input_string('_scope', rcube_utils::INPUT_GET));
$interval = trim(rcube_utils::get_input_string('_interval', rcube_utils::INPUT_GET));
$continue = trim(rcube_utils::get_input_string('_continue', rcube_utils::INPUT_GET));
// Set message set for already stored (but incomplete) search request
if (!empty($continue) && isset($_SESSION['search']) && $_SESSION['search_request'] == $continue) {
$rcmail->storage->set_search_set($_SESSION['search']);
$search = $_SESSION['search'][0];
$search_request = $continue;
} else {
// Parse input parameters into an IMAP search criteria
$search = self::search_input($str, $headers, $filter, $interval);
$search_request = md5($mbox . $scope . $interval . $filter . $str);
// Save search modifiers for the current folder to user prefs
if (strlen($search) && strlen($headers) && strlen($mbox) && $scope != 'all') {
self::update_search_mods($mbox, $headers);
}
}
$sort_column = self::sort_column();
$sort_order = self::sort_order();
// execute IMAP search
if ($search) {
$mboxes = [];
// search all, current or subfolders folders
if ($scope == 'all') {
$mboxes = $rcmail->storage->list_folders_subscribed('', '*', 'mail', null, true);
// we want natural alphabetic sorting of folders in the result set
natcasesort($mboxes);
} elseif ($scope == 'sub') {
$delim = $rcmail->storage->get_hierarchy_delimiter();
$mboxes = $rcmail->storage->list_folders_subscribed($mbox . $delim, '*', 'mail');
array_unshift($mboxes, $mbox);
}
if ($scope != 'all') {
// Remember current folder, it can change in meantime (plugins)
// but we need it to e.g. recognize Sent folder to handle From/To column later
$rcmail->output->set_env('mailbox', $mbox);
}
$result = $rcmail->storage->search($mboxes, $search, RCUBE_CHARSET, $sort_column);
}
// save search results in session
if (!isset($_SESSION['search']) || !is_array($_SESSION['search'])) {
$_SESSION['search'] = [];
}
if ($search) {
$_SESSION['search'] = $rcmail->storage->get_search_set();
$_SESSION['last_text_search'] = $str;
}
$_SESSION['search_request'] = $search_request;
$_SESSION['search_scope'] = $scope;
$_SESSION['search_interval'] = $interval;
$_SESSION['search_filter'] = $filter;
// Get the headers
if (!isset($result) || empty($result->incomplete)) {
$result_h = $rcmail->storage->list_messages($mbox, 1, $sort_column, $sort_order);
}
// Make sure we got the headers
if (!empty($result_h)) {
$count = $rcmail->storage->count($mbox, $rcmail->storage->get_threading() ? 'THREADS' : 'ALL');
self::js_message_list($result_h, false);
if ($search) {
$all_count = $rcmail->storage->count(null, 'ALL');
$rcmail->output->show_message('searchsuccessful', 'confirmation', ['nr' => $all_count]);
}
// remember last HIGHESTMODSEQ value (if supported)
// we need it for flag updates in check-recent
if (strlen($mbox)) {
$data = $rcmail->storage->folder_data($mbox);
if (!empty($data['HIGHESTMODSEQ'])) {
$_SESSION['list_mod_seq'] = $data['HIGHESTMODSEQ'];
}
}
}
// handle IMAP errors (e.g. #1486905)
elseif ($err_code = $rcmail->storage->get_error_code()) {
$count = 0;
self::display_server_error();
}
// advice the client to re-send the (cross-folder) search request
elseif (!empty($result) && !empty($result->incomplete)) {
$count = 0; // keep UI locked
$rcmail->output->command('continue_search', $search_request);
} else {
$count = 0;
$rcmail->output->show_message('searchnomatch', 'notice');
$rcmail->output->set_env('multifolder_listing', isset($result) ? !empty($result->multi) : false);
if (isset($result) && !empty($result->multi) && $scope == 'all') {
$rcmail->output->command('select_folder', '');
}
}
// update message count display
$rcmail->output->set_env('search_request', $search ? $search_request : '');
$rcmail->output->set_env('search_filter', $_SESSION['search_filter']);
$rcmail->output->set_env('messagecount', $count);
$rcmail->output->set_env('pagecount', ceil($count / $rcmail->storage->get_pagesize()));
$rcmail->output->set_env('exists', !strlen($mbox) ? 0 : $rcmail->storage->count($mbox, 'EXISTS'));
$rcmail->output->command('set_rowcount', self::get_messagecount_text($count, 1), $mbox);
self::list_pagetitle();
// update unseen messages count
if ($search === '') {
self::send_unread_count($mbox, false, empty($result_h) ? 0 : null);
}
if (isset($result) && empty($result->incomplete)) {
$rcmail->output->command('set_quota', self::quota_content(null, !empty($result->multi) ? 'INBOX' : $mbox));
}
$rcmail->output->send();
}
/**
* Creates BEFORE/SINCE search criteria from the specified interval
* Interval can be: 1W, 1M, 1Y, -1W, -1M, -1Y
*/
public static function search_interval_criteria($interval)
{
if (empty($interval)) {
return;
}
$interval = strtoupper($interval);
if ($interval[0] == '-') {
$search = 'BEFORE';
$interval = substr($interval, 1);
} else {
$search = 'SINCE';
}
$date = new DateTime('now');
$interval = new DateInterval('P' . $interval);
$date->sub($interval);
return $search . ' ' . $date->format('j-M-Y');
}
/**
* Parse search input.
*
* @param string $str Search string
* @param string $headers Comma-separated list of headers/fields to search in
* @param string $filter Additional IMAP filter query
* @param string $interval Additional interval filter
*
* @return string IMAP search query
*/
public static function search_input($str, $headers = '', $filter = 'ALL', $interval = null)
{
$headers = $headers ? explode(',', $headers) : ['subject'];
// Add list filter string
$result = $filter && $filter != 'ALL' ? $filter : '';
// Add the interval filter string
if ($search_interval = self::search_interval_criteria($interval)) {
$result .= ' ' . $search_interval;
}
$value_function = static function ($value) {
$value = trim($value);
$value = preg_replace('/(^"|"$)/', '', $value);
$value = str_replace('\"', '"', $value);
return $value;
};
// Explode the search input into "tokens"
$parts = rcube_utils::explode_quoted_string('\s+', $str);
$parts = array_filter($parts);
foreach ($parts as $idx => $part) {
if (strcasecmp($part, 'OR') === 0) {
$parts[$idx] = 'OR';
continue;
}
if (strcasecmp($part, 'AND') === 0) {
$parts[$idx] = 'AND';
continue;
}
$not = '';
if (preg_match('/^(-?[a-zA-Z-]+):(.*)$/', $part, $matches)) {
$option = $matches[1];
$value = $value_function($matches[2]);
if ($option[0] == '-') {
$not = 'NOT ';
$option = substr($option, 1);
}
if ($imap_query = self::search_input_option($option, $value)) {
$parts[$idx] = $not . $imap_query;
continue;
}
}
if (preg_match('/^-".*"$/', $part)) {
$not = 'NOT ';
$part = substr($part, 1);
}
if ($imap_query = self::search_input_text($value_function($part), $headers)) {
$parts[$idx] = $not . $imap_query;
}
}
foreach ($parts as $idx => $part) {
if ($part == 'OR') {
// Ignore OR on the start and end, and successive ORs
if ($idx === 0 || !isset($parts[$idx + 1]) || $parts[$idx + 1] == 'OR') {
unset($parts[$idx]);
continue;
}
$index = $idx;
while ($index-- >= 0) {
if (isset($parts[$index])) {
$parts[$index] = 'OR ' . $parts[$index];
break;
}
}
unset($parts[$idx]);
} elseif ($part == 'AND') {
unset($parts[$idx]);
}
}
$result = trim($result . ' ' . implode(' ', $parts));
return $result != 'ALL' ? $result : '';
}
/**
* Parse search input token.
*
* @param string $option Option name
* @param string $value Option value
*
* @return ?string IMAP search query, NULL if the option is unsupported
*/
protected static function search_input_option($option, $value)
{
if (!strlen($value)) {
return null;
}
$supported = ['subject', 'from', 'to', 'cc', 'bcc'];
$option = strtolower($option);
$escaped = rcube_imap_generic::escape($value);
switch ($option) {
case 'body':
return "BODY {$escaped}";
case 'text':
return "TEXT {$escaped}";
case 'replyto':
case 'reply-to':
return "OR HEADER REPLY-TO {$escaped} HEADER MAIL-REPLY-TO {$escaped}";
case 'followupto':
case 'followup-to':
return "OR HEADER FOLLOWUP-TO {$escaped} HEADER MAIL-FOLLOWUP-TO {$escaped}";
case 'larger':
case 'smaller':
if (preg_match('/([0-9\.]+)(k|m|g|b|kb|mb|gb)/i', $value)) {
return strtoupper($option) . ' ' . parse_bytes($value);
}
break;
case 'is':
$map = [
'unread' => 'UNSEEN',
'read' => 'SEEN',
'unseen' => 'UNSEEN',
'seen' => 'SEEN',
'flagged' => 'FLAGGED',
'unflagged' => 'UNFLAGGED',
'deleted' => 'DELETED',
'undeleted' => 'UNDELETED',
'answered' => 'ANSWERED',
'unanswered' => 'UNANSWERED',
];
$value = strtolower($value);
if (isset($map[$value])) {
return $map[$value];
}
break;
case 'has':
if ($value == 'attachment') {
// Content-Type values of messages with attachments
// the same as in app.js:add_message_row()
$ctypes = ['application/', 'multipart/mixed', 'multipart/signed', 'multipart/report'];
// Build search string of "with attachment" filter
$result = str_repeat(' OR', count($ctypes) - 1);
foreach ($ctypes as $type) {
$result .= ' HEADER Content-Type ' . rcube_imap_generic::escape($type);
}
return trim($result);
}
break;
case 'older_than': // GMail alias
$option = 'before';
case 'newer_than': // GMail alias
$option = 'since';
case 'since':
case 'before':
if (preg_match('/^[0-9]+[WMY]$/i', $value)) {
if ($option == 'before') {
$value = "-{$value}";
}
if ($search_interval = self::search_interval_criteria(strtoupper($value))) {
return $search_interval;
}
} elseif (preg_match('|^([0-9]{4})[-/]([0-9]{1,2})[-/]([0-9]{1,2})$|i', $value, $m)) {
$dt = new DateTime(sprintf('%04d-%02d-%02d', $m[1], $m[2], $m[3]) . 'T00:00:00Z');
return strtoupper($option) . ' ' . $dt->format('j-M-Y');
}
break;
default:
if (in_array($option, $supported)) {
$header = strtoupper($option);
return "HEADER {$header} {$escaped}";
}
}
return null;
}
/**
* Converts search text into an imap query
*
* @param string $text Search text
* @param array $headers List of headers/fields to search in
*
* @return string IMAP search query
*/
protected static function search_input_text($text, $headers)
{
$query = [];
foreach ($headers as $header) {
if ($imap = self::search_input_option($header, $text)) {
$query[$header] = $imap;
}
}
$result = '';
if (!empty($query)) {
if (($size = count($query)) > 1) {
$result .= str_repeat('OR ', $size - 1);
}
$result .= implode(' ', $query);
}
return $result;
}
/**
* Update search mods for the specified folder
*
* @param string $mbox Folder name
* @param string $headers Headers list input (comma-separated)
*/
protected static function update_search_mods($mbox, $headers)
{
$supported = ['subject', 'from', 'to', 'cc', 'bcc', 'replyto', 'followupto', 'body', 'text'];
$headers = explode(',', strtolower($headers));
$headers = array_intersect($headers, $supported);
$search_mods = self::search_mods();
$search_mods_value = array_fill_keys($headers, 1);
if (!isset($search_mods[$mbox]) || $search_mods[$mbox] != $search_mods_value) {
$search_mods[$mbox] = $search_mods_value;
rcmail::get_instance()->user->save_prefs(['search_mods' => $search_mods]);
}
}
}