4 Classes for managesieve operations (using PEAR::Net_Sieve)
6 Author: Aleksander Machniak <alec@alec.pl>
8 $Id: rcube_sieve.php 4555 2011-02-16 10:48:11Z alec $
12 // Sieve Language Basics: http://www.ietf.org/rfc/rfc5228.txt
14 define('SIEVE_ERROR_CONNECTION', 1);
15 define('SIEVE_ERROR_LOGIN', 2);
16 define('SIEVE_ERROR_NOT_EXISTS', 3); // script not exists
17 define('SIEVE_ERROR_INSTALL', 4); // script installation
18 define('SIEVE_ERROR_ACTIVATE', 5); // script activation
19 define('SIEVE_ERROR_DELETE', 6); // script deletion
20 define('SIEVE_ERROR_INTERNAL', 7); // internal error
21 define('SIEVE_ERROR_DEACTIVATE', 8); // script activation
22 define('SIEVE_ERROR_OTHER', 255); // other/unknown error
27 private $sieve; // Net_Sieve object
28 private $error = false; // error flag
29 private $list = array(); // scripts list
31 public $script; // rcube_sieve_script object
32 public $current; // name of currently loaded script
33 private $disabled; // array of disabled extensions
39 * @param string Username (for managesieve login)
40 * @param string Password (for managesieve login)
41 * @param string Managesieve server hostname/address
42 * @param string Managesieve server port number
43 * @param string Managesieve authentication method
44 * @param boolean Enable/disable TLS use
45 * @param array Disabled extensions
46 * @param boolean Enable/disable debugging
47 * @param string Proxy authentication identifier
48 * @param string Proxy authentication password
50 public function __construct($username, $password='', $host='localhost', $port=2000,
51 $auth_type=null, $usetls=true, $disabled=array(), $debug=false,
52 $auth_cid=null, $auth_pw=null)
54 $this->sieve = new Net_Sieve();
57 $this->sieve->setDebug(true, array($this, 'debug_handler'));
60 if (PEAR::isError($this->sieve->connect($host, $port, null, $usetls))) {
61 return $this->_set_error(SIEVE_ERROR_CONNECTION);
64 if (!empty($auth_cid)) {
66 $username = $auth_cid;
70 if (PEAR::isError($this->sieve->login($username, $password,
71 $auth_type ? strtoupper($auth_type) : null, $authz))
73 return $this->_set_error(SIEVE_ERROR_LOGIN);
76 $this->disabled = $disabled;
79 public function __destruct() {
80 $this->sieve->disconnect();
84 * Getter for error code
86 public function error()
88 return $this->error ? $this->error : false;
92 * Saves current script into server
94 public function save($name = null)
97 return $this->_set_error(SIEVE_ERROR_INTERNAL);
100 return $this->_set_error(SIEVE_ERROR_INTERNAL);
103 $name = $this->current;
105 $script = $this->script->as_text();
108 $script = '/* empty script */';
110 if (PEAR::isError($this->sieve->installScript($name, $script)))
111 return $this->_set_error(SIEVE_ERROR_INSTALL);
117 * Saves text script into server
119 public function save_script($name, $content = null)
122 return $this->_set_error(SIEVE_ERROR_INTERNAL);
125 $content = '/* empty script */';
127 if (PEAR::isError($this->sieve->installScript($name, $content)))
128 return $this->_set_error(SIEVE_ERROR_INSTALL);
134 * Activates specified script
136 public function activate($name = null)
139 return $this->_set_error(SIEVE_ERROR_INTERNAL);
142 $name = $this->current;
144 if (PEAR::isError($this->sieve->setActive($name)))
145 return $this->_set_error(SIEVE_ERROR_ACTIVATE);
151 * De-activates specified script
153 public function deactivate()
156 return $this->_set_error(SIEVE_ERROR_INTERNAL);
158 if (PEAR::isError($this->sieve->setActive('')))
159 return $this->_set_error(SIEVE_ERROR_DEACTIVATE);
165 * Removes specified script
167 public function remove($name = null)
170 return $this->_set_error(SIEVE_ERROR_INTERNAL);
173 $name = $this->current;
175 // script must be deactivated first
176 if ($name == $this->sieve->getActive())
177 if (PEAR::isError($this->sieve->setActive('')))
178 return $this->_set_error(SIEVE_ERROR_DELETE);
180 if (PEAR::isError($this->sieve->removeScript($name)))
181 return $this->_set_error(SIEVE_ERROR_DELETE);
183 if ($name == $this->current)
184 $this->current = null;
190 * Gets list of supported by server Sieve extensions
192 public function get_extensions()
195 return $this->_set_error(SIEVE_ERROR_INTERNAL);
197 $ext = $this->sieve->getExtensions();
198 // we're working on lower-cased names
199 $ext = array_map('strtolower', (array) $ext);
202 $supported = $this->script->get_extensions();
203 foreach ($ext as $idx => $ext_name)
204 if (!in_array($ext_name, $supported))
208 return array_values($ext);
212 * Gets list of scripts from server
214 public function get_scripts()
219 return $this->_set_error(SIEVE_ERROR_INTERNAL);
221 $this->list = $this->sieve->listScripts();
223 if (PEAR::isError($this->list))
224 return $this->_set_error(SIEVE_ERROR_OTHER);
231 * Returns active script name
233 public function get_active()
236 return $this->_set_error(SIEVE_ERROR_INTERNAL);
238 return $this->sieve->getActive();
242 * Loads script by name
244 public function load($name)
247 return $this->_set_error(SIEVE_ERROR_INTERNAL);
249 if ($this->current == $name)
252 $script = $this->sieve->getScript($name);
254 if (PEAR::isError($script))
255 return $this->_set_error(SIEVE_ERROR_OTHER);
257 // try to parse from Roundcube format
258 $this->script = $this->_parse($script);
260 $this->current = $name;
266 * Loads script from text content
268 public function load_script($script)
271 return $this->_set_error(SIEVE_ERROR_INTERNAL);
273 // try to parse from Roundcube format
274 $this->script = $this->_parse($script);
278 * Creates rcube_sieve_script object from text script
280 private function _parse($txt)
282 // try to parse from Roundcube format
283 $script = new rcube_sieve_script($txt, $this->disabled);
285 // ... else try to import from different formats
286 if (empty($script->content)) {
287 $script = $this->_import_rules($txt);
288 $script = new rcube_sieve_script($script, $this->disabled);
291 // replace all elsif with if+stop, we support only ifs
292 foreach ($script->content as $idx => $rule) {
293 if (!isset($script->content[$idx+1])
294 || preg_match('/^else|elsif$/', $script->content[$idx+1]['type'])) {
296 if (!preg_match('/^(stop|vacation)$/', $rule['actions'][count($rule['actions'])-1]['type'])) {
297 $script->content[$idx]['actions'][] = array(
308 * Gets specified script as text
310 public function get_script($name)
313 return $this->_set_error(SIEVE_ERROR_INTERNAL);
315 $content = $this->sieve->getScript($name);
317 if (PEAR::isError($content))
318 return $this->_set_error(SIEVE_ERROR_OTHER);
324 * Creates empty script or copy of other script
326 public function copy($name, $copy)
329 return $this->_set_error(SIEVE_ERROR_INTERNAL);
332 $content = $this->sieve->getScript($copy);
334 if (PEAR::isError($content))
335 return $this->_set_error(SIEVE_ERROR_OTHER);
338 return $this->save_script($name, $content);
341 private function _import_rules($script)
346 // Squirrelmail (Avelsieve)
347 if ($tokens = preg_split('/(#START_SIEVE_RULE.*END_SIEVE_RULE)\r?\n/', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
348 foreach($tokens as $token) {
349 if (preg_match('/^#START_SIEVE_RULE.*/', $token, $matches)) {
350 $name[$i] = "unnamed rule ".($i+1);
351 $content .= "# rule:[".$name[$i]."]\n";
353 elseif (isset($name[$i])) {
354 // This preg_replace is added because I've found some Avelsieve scripts
355 // with rules containing "if" here. I'm not sure it was working
356 // before without this or not.
357 $token = preg_replace('/^if\s+/', '', trim($token));
358 $content .= "if $token\n";
364 else if ($tokens = preg_split('/(# .+)\r?\n/i', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
365 foreach($tokens as $token) {
366 if (preg_match('/^# (.+)/i', $token, $matches)) {
367 $name[$i] = $matches[1];
368 $content .= "# rule:[" . $name[$i] . "]\n";
370 elseif (isset($name[$i])) {
371 $token = str_replace(":comparator \"i;ascii-casemap\" ", "", $token);
372 $content .= $token . "\n";
381 private function _set_error($error)
383 $this->error = $error;
388 * This is our own debug handler for connection
390 public function debug_handler(&$sieve, $message)
392 write_log('sieve', preg_replace('/\r\n$/', '', $message));
397 class rcube_sieve_script
399 public $content = array(); // script rules array
401 private $supported = array( // extensions supported by class
406 'vacation', // RFC5230
407 'relational', // RFC3431
408 // TODO: (most wanted first) body, imapflags, notify, regex
414 * @param string Script's text content
415 * @param array Disabled extensions
417 public function __construct($script, $disabled=null)
419 if (!empty($disabled)) {
420 // we're working on lower-cased names
421 $disabled = array_map('strtolower', (array) $disabled);
422 foreach ($disabled as $ext) {
423 if (($idx = array_search($ext, $this->supported)) !== false) {
424 unset($this->supported[$idx]);
429 $this->content = $this->_parse_text($script);
433 * Adds script contents as text to the script array (at the end)
435 * @param string Text script contents
437 public function add_text($script)
439 $content = $this->_parse_text($script);
442 // check existsing script rules names
443 foreach ($this->content as $idx => $elem) {
444 $names[$elem['name']] = $idx;
447 foreach ($content as $elem) {
448 if (!isset($names[$elem['name']])) {
449 array_push($this->content, $elem);
458 * Adds rule to the script (at the end)
460 * @param string Rule name
461 * @param array Rule content (as array)
463 public function add_rule($content)
465 // TODO: check this->supported
466 array_push($this->content, $content);
467 return sizeof($this->content)-1;
470 public function delete_rule($index)
472 if(isset($this->content[$index])) {
473 unset($this->content[$index]);
479 public function size()
481 return sizeof($this->content);
484 public function update_rule($index, $content)
486 // TODO: check this->supported
487 if ($this->content[$index]) {
488 $this->content[$index] = $content;
495 * Returns script as text
497 public function as_text()
504 foreach ($this->content as $rule) {
510 $script .= '# rule:[' . $rule['name'] . "]\n";
512 // constraints expressions
513 foreach ($rule['tests'] as $test) {
515 switch ($test['test']) {
517 $tests[$i] .= ($test['not'] ? 'not ' : '');
518 $tests[$i] .= 'size :' . ($test['type']=='under' ? 'under ' : 'over ') . $test['arg'];
521 $tests[$i] .= ($test['not'] ? 'false' : 'true');
524 $tests[$i] .= ($test['not'] ? 'not ' : '');
525 $tests[$i] .= 'exists ' . self::escape_string($test['arg']);
528 $tests[$i] .= ($test['not'] ? 'not ' : '');
530 // relational operator + comparator
531 if (preg_match('/^(value|count)-([gteqnl]{2})/', $test['type'], $m)) {
532 array_push($exts, 'relational');
533 array_push($exts, 'comparator-i;ascii-numeric');
534 $tests[$i] .= 'header :' . $m[1] . ' "' . $m[2] . '" :comparator "i;ascii-numeric"';
537 $tests[$i] .= 'header :' . $test['type'];
539 $tests[$i] .= ' ' . self::escape_string($test['arg1']);
540 $tests[$i] .= ' ' . self::escape_string($test['arg2']);
546 // disabled rule: if false #....
547 $script .= 'if ' . ($rule['disabled'] ? 'false # ' : '');
552 else if (count($tests) > 1) {
553 $tests_str = implode(', ', $tests);
556 $tests_str = $tests[0];
559 if ($rule['join'] || count($tests) > 1) {
560 $script .= sprintf('%s (%s)', $rule['join'] ? 'allof' : 'anyof', $tests_str);
563 $script .= $tests_str;
568 foreach ($rule['actions'] as $action) {
569 switch ($action['type']) {
571 array_push($exts, 'fileinto');
572 $script .= "\tfileinto ";
573 if ($action['copy']) {
575 array_push($exts, 'copy');
577 $script .= self::escape_string($action['target']) . ";\n";
580 $script .= "\tredirect ";
581 if ($action['copy']) {
583 array_push($exts, 'copy');
585 $script .= self::escape_string($action['target']) . ";\n";
589 array_push($exts, $action['type']);
590 $script .= "\t".$action['type']." "
591 . self::escape_string($action['target']) . ";\n";
596 $script .= "\t" . $action['type'] .";\n";
599 array_push($exts, 'vacation');
600 $script .= "\tvacation";
601 if (!empty($action['days']))
602 $script .= " :days " . $action['days'];
603 if (!empty($action['addresses']))
604 $script .= " :addresses " . self::escape_string($action['addresses']);
605 if (!empty($action['subject']))
606 $script .= " :subject " . self::escape_string($action['subject']);
607 if (!empty($action['handle']))
608 $script .= " :handle " . self::escape_string($action['handle']);
609 if (!empty($action['from']))
610 $script .= " :from " . self::escape_string($action['from']);
611 if (!empty($action['mime']))
613 $script .= " " . self::escape_string($action['reason']) . ";\n";
624 $script = 'require ["' . implode('","', array_unique($exts)) . "\"];\n" . $script;
630 * Returns script object
633 public function as_array()
635 return $this->content;
639 * Returns array of supported extensions
642 public function get_extensions()
644 return array_values($this->supported);
648 * Converts text script to rules array
650 * @param string Text script
652 private function _parse_text($script)
658 if ($tokens = preg_split('/(# rule:\[.*\])\r?\n/', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
659 foreach($tokens as $token) {
660 if (preg_match('/^# rule:\[(.*)\]/', $token, $matches)) {
661 $content[$i]['name'] = $matches[1];
663 else if (isset($content[$i]['name']) && sizeof($content[$i]) == 1) {
664 if ($rule = $this->_tokenize_rule($token)) {
665 $content[$i] = array_merge($content[$i], $rule);
668 else // unknown rule format
678 * Convert text script fragment to rule object
680 * @param string Text rule
682 private function _tokenize_rule($content)
684 $cond = strtolower(self::tokenize($content, 1));
686 if ($cond != 'if' && $cond != 'elsif' && $cond != 'else') {
693 // disabled rule (false + comment): if false # .....
694 if (preg_match('/^\s*false\s+#/i', $content)) {
695 $content = preg_replace('/^\s*false\s+#\s*/i', '', $content);
699 while (strlen($content)) {
700 $tokens = self::tokenize($content, true);
701 $separator = array_pop($tokens);
703 if (!empty($tokens)) {
704 $token = array_shift($tokens);
710 $token = strtolower($token);
712 if ($token == 'not') {
714 $token = strtolower(array_shift($tokens));
728 $size = array('test' => 'size', 'not' => $not);
729 for ($i=0, $len=count($tokens); $i<$len; $i++) {
730 if (!is_array($tokens[$i])
731 && preg_match('/^:(under|over)$/i', $tokens[$i])
733 $size['type'] = strtolower(substr($tokens[$i], 1));
736 $size['arg'] = $tokens[$i];
744 $header = array('test' => 'header', 'not' => $not, 'arg1' => '', 'arg2' => '');
745 for ($i=0, $len=count($tokens); $i<$len; $i++) {
746 if (!is_array($tokens[$i]) && preg_match('/^:comparator$/i', $tokens[$i])) {
749 else if (!is_array($tokens[$i]) && preg_match('/^:(count|value)$/i', $tokens[$i])) {
750 $header['type'] = strtolower(substr($tokens[$i], 1)) . '-' . $tokens[++$i];
752 else if (!is_array($tokens[$i]) && preg_match('/^:(is|contains|matches)$/i', $tokens[$i])) {
753 $header['type'] = strtolower(substr($tokens[$i], 1));
756 $header['arg1'] = $header['arg2'];
757 $header['arg2'] = $tokens[$i];
765 $tests[] = array('test' => 'exists', 'not' => $not,
766 'arg' => array_pop($tokens));
770 $tests[] = array('test' => 'true', 'not' => $not);
774 $tests[] = array('test' => 'true', 'not' => !$not);
779 if ($separator == '{') {
784 // ...and actions block
786 $actions = $this->_parse_actions($content);
789 if ($tests && $actions) {
793 'actions' => $actions,
795 'disabled' => $disabled,
803 * Parse body of actions section
805 * @param string Text body
806 * @return array Array of parsed action type/target pairs
808 private function _parse_actions($content)
812 while (strlen($content)) {
813 $tokens = self::tokenize($content, true);
814 $separator = array_pop($tokens);
816 if (!empty($tokens)) {
817 $token = array_shift($tokens);
827 $result[] = array('type' => $token);
835 for ($i=0, $len=count($tokens); $i<$len; $i++) {
836 if (strtolower($tokens[$i]) == ':copy') {
840 $target = $tokens[$i];
844 $result[] = array('type' => $token, 'copy' => $copy,
845 'target' => $target);
850 $result[] = array('type' => $token, 'target' => array_pop($tokens));
854 $vacation = array('type' => 'vacation', 'reason' => array_pop($tokens));
856 for ($i=0, $len=count($tokens); $i<$len; $i++) {
857 $tok = strtolower($tokens[$i]);
858 if ($tok == ':days') {
859 $vacation['days'] = $tokens[++$i];
861 else if ($tok == ':subject') {
862 $vacation['subject'] = $tokens[++$i];
864 else if ($tok == ':addresses') {
865 $vacation['addresses'] = $tokens[++$i];
867 else if ($tok == ':handle') {
868 $vacation['handle'] = $tokens[++$i];
870 else if ($tok == ':from') {
871 $vacation['from'] = $tokens[++$i];
873 else if ($tok == ':mime') {
874 $vacation['mime'] = true;
878 $result[] = $vacation;
887 * Escape special chars into quoted string value or multi-line string
890 * @param string $str Text or array (list) of strings
892 * @return string Result text
894 static function escape_string($str)
896 if (is_array($str) && count($str) > 1) {
897 foreach($str as $idx => $val)
898 $str[$idx] = self::escape_string($val);
900 return '[' . implode(',', $str) . ']';
902 else if (is_array($str)) {
903 $str = array_pop($str);
907 if (preg_match('/[\r\n\0]/', $str) || strlen($str) > 1024) {
908 return sprintf("text:\n%s\n.\n", self::escape_multiline_string($str));
912 $replace = array('\\' => '\\\\', '"' => '\\"');
913 $str = str_replace(array_keys($replace), array_values($replace), $str);
914 return '"' . $str . '"';
919 * Escape special chars in multi-line string value
921 * @param string $str Text
923 * @return string Text
925 static function escape_multiline_string($str)
927 $str = preg_split('/(\r?\n)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE);
929 foreach ($str as $idx => $line) {
931 if (isset($line[0]) && $line[0] == '.') {
932 $str[$idx] = '.' . $line;
936 return implode($str);
940 * Splits script into string tokens
942 * @param string &$str The script
943 * @param mixed $num Number of tokens to return, 0 for all
944 * or True for all tokens until separator is found.
945 * Separator will be returned as last token.
946 * @param int $in_list Enable to called recursively inside a list
948 * @return mixed Tokens array or string if $num=1
950 static function tokenize(&$str, $num=0, $in_list=false)
954 // remove spaces from the beginning of the string
955 while (($str = ltrim($str)) !== ''
956 && (!$num || $num === true || count($result) < $num)
964 for ($pos=1; $pos<$len; $pos++) {
965 if ($str[$pos] == '"') {
968 if ($str[$pos] == "\\") {
969 if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
974 if ($str[$pos] != '"') {
977 // we need to strip slashes for a quoted string
978 $result[] = stripslashes(substr($str, 1, $pos - 1));
979 $str = substr($str, $pos + 1);
982 // Parenthesized list
984 $str = substr($str, 1);
985 $result[] = self::tokenize($str, 0, true);
988 $str = substr($str, 1);
992 // list/test separator
1002 $str = substr($str, 1);
1003 if ($num === true) {
1011 if ($str[1] == '*') {
1012 if ($end_pos = strpos($str, '*/')) {
1013 $str = substr($str, $end_pos + 2);
1024 if ($lf_pos = strpos($str, "\n")) {
1025 $str = substr($str, $lf_pos);
1034 // empty or one character
1038 if (strlen($str) < 2) {
1044 // tag/identifier/number
1045 if (preg_match('/^([a-z0-9:_]+)/i', $str, $m)) {
1046 $str = substr($str, strlen($m[1]));
1048 if ($m[1] != 'text:') {
1053 // possible hash-comment after "text:"
1054 if (preg_match('/^( |\t)*(#[^\n]+)?\n/', $str, $m)) {
1055 $str = substr($str, strlen($m[0]));
1057 // get text until alone dot in a line
1058 if (preg_match('/^(.*)\r?\n\.\r?\n/sU', $str, $m)) {
1060 // remove dot-stuffing
1061 $text = str_replace("\n..", "\n.", $text);
1062 $str = substr($str, strlen($m[0]));
1076 return $num === 1 ? (isset($result[0]) ? $result[0] : null) : $result;