]> git.donarmstrong.com Git - roundcube.git/blob - plugins/managesieve/lib/rcube_sieve_script.php
871fb1401623c94110e175b22dc3e7944478e8fa
[roundcube.git] / plugins / managesieve / lib / rcube_sieve_script.php
1 <?php
2
3 /**
4   Class for operations on Sieve scripts
5
6   Author: Aleksander Machniak <alec@alec.pl>
7
8   $Id: rcube_sieve_script.php 4806 2011-05-24 08:32:01Z alec $
9
10 */
11
12 class rcube_sieve_script
13 {
14     public $content = array();      // script rules array
15
16     private $supported = array(     // extensions supported by class
17         'fileinto',                 // RFC3028
18         'reject',                   // RFC5429
19         'ereject',                  // RFC5429
20         'copy',                     // RFC3894
21         'vacation',                 // RFC5230
22         'relational',               // RFC3431
23         'regex',                    // draft-ietf-sieve-regex-01
24         'imapflags',                // draft-melnikov-sieve-imapflags-06
25         'imap4flags',               // RFC5232
26         // TODO: body, notify
27     );
28
29     private $capabilities;
30
31     /**
32      * Object constructor
33      *
34      * @param  string  Script's text content
35      * @param  array   List of disabled extensions
36      * @param  array   List of capabilities supported by server
37      */
38     public function __construct($script, $disabled=null, $capabilities=null)
39     {
40         if (!empty($disabled)) {
41             // we're working on lower-cased names
42             $disabled = array_map('strtolower', (array) $disabled);
43             foreach ($disabled as $ext) {
44                 if (($idx = array_search($ext, $this->supported)) !== false) {
45                     unset($this->supported[$idx]);
46                 }
47             }
48         }
49
50         $this->capabilities = $capabilities;
51         $this->content      = $this->_parse_text($script);
52     }
53
54     /**
55      * Adds script contents as text to the script array (at the end)
56      *
57      * @param    string    Text script contents
58      */
59     public function add_text($script)
60     {
61         $content = $this->_parse_text($script);
62         $result = false;
63
64         // check existsing script rules names
65         foreach ($this->content as $idx => $elem) {
66             $names[$elem['name']] = $idx;
67         }
68
69         foreach ($content as $elem) {
70             if (!isset($names[$elem['name']])) {
71                 array_push($this->content, $elem);
72                 $result = true;
73             }
74         }
75
76         return $result;
77     }
78
79     /**
80      * Adds rule to the script (at the end)
81      *
82      * @param string Rule name
83      * @param array  Rule content (as array)
84      */
85     public function add_rule($content)
86     {
87         // TODO: check this->supported
88         array_push($this->content, $content);
89         return sizeof($this->content)-1;
90     }
91
92     public function delete_rule($index)
93     {
94         if(isset($this->content[$index])) {
95             unset($this->content[$index]);
96             return true;
97         }
98         return false;
99     }
100
101     public function size()
102     {
103         return sizeof($this->content);
104     }
105
106     public function update_rule($index, $content)
107     {
108         // TODO: check this->supported
109         if ($this->content[$index]) {
110             $this->content[$index] = $content;
111             return $index;
112         }
113         return false;
114     }
115
116     /**
117      * Returns script as text
118      */
119     public function as_text()
120     {
121         $script = '';
122         $exts = array();
123         $idx = 0;
124
125         // rules
126         foreach ($this->content as $rule) {
127             $extension = '';
128             $tests = array();
129             $i = 0;
130
131             // header
132             $script .= '# rule:[' . $rule['name'] . "]\n";
133
134             // constraints expressions
135             foreach ($rule['tests'] as $test) {
136                 $tests[$i] = '';
137                 switch ($test['test']) {
138                 case 'size':
139                     $tests[$i] .= ($test['not'] ? 'not ' : '');
140                     $tests[$i] .= 'size :' . ($test['type']=='under' ? 'under ' : 'over ') . $test['arg'];
141                     break;
142                 case 'true':
143                     $tests[$i] .= ($test['not'] ? 'false' : 'true');
144                     break;
145                 case 'exists':
146                     $tests[$i] .= ($test['not'] ? 'not ' : '');
147                     $tests[$i] .= 'exists ' . self::escape_string($test['arg']);
148                     break;
149                 case 'header':
150                     $tests[$i] .= ($test['not'] ? 'not ' : '');
151
152                     // relational operator + comparator
153                                         if (preg_match('/^(value|count)-([gteqnl]{2})/', $test['type'], $m)) {
154                                                 array_push($exts, 'relational');
155                                                 array_push($exts, 'comparator-i;ascii-numeric');
156
157                         $tests[$i] .= 'header :' . $m[1] . ' "' . $m[2] . '" :comparator "i;ascii-numeric"';
158                     }
159                     else {
160                                             if ($test['type'] == 'regex') {
161                                                     array_push($exts, 'regex');
162                         }
163
164                         $tests[$i] .= 'header :' . $test['type'];
165                     }
166
167                     $tests[$i] .= ' ' . self::escape_string($test['arg1']);
168                     $tests[$i] .= ' ' . self::escape_string($test['arg2']);
169                     break;
170                 }
171                 $i++;
172             }
173
174             // disabled rule: if false #....
175             $script .= 'if ' . ($rule['disabled'] ? 'false # ' : '');
176
177             if (empty($tests)) {
178                 $tests_str = 'true';
179             }
180             else if (count($tests) > 1) {
181                 $tests_str = implode(', ', $tests);
182             }
183             else {
184                 $tests_str = $tests[0];
185             }
186
187             if ($rule['join'] || count($tests) > 1) {
188                 $script .= sprintf('%s (%s)', $rule['join'] ? 'allof' : 'anyof', $tests_str);
189             }
190             else {
191                 $script .= $tests_str;
192             }
193             $script .= "\n{\n";
194
195             // action(s)
196             foreach ($rule['actions'] as $action) {
197                 switch ($action['type']) {
198
199                 case 'fileinto':
200                     array_push($exts, 'fileinto');
201                     $script .= "\tfileinto ";
202                     if ($action['copy']) {
203                         $script .= ':copy ';
204                         array_push($exts, 'copy');
205                     }
206                     $script .= self::escape_string($action['target']) . ";\n";
207                     break;
208
209                 case 'redirect':
210                     $script .= "\tredirect ";
211                     if ($action['copy']) {
212                         $script .= ':copy ';
213                         array_push($exts, 'copy');
214                     }
215                     $script .= self::escape_string($action['target']) . ";\n";
216                     break;
217
218                 case 'reject':
219                 case 'ereject':
220                     array_push($exts, $action['type']);
221                     $script .= "\t".$action['type']." "
222                         . self::escape_string($action['target']) . ";\n";
223                     break;
224
225                 case 'addflag':
226                 case 'setflag':
227                 case 'removeflag':
228                     if (is_array($this->capabilities) && in_array('imap4flags', $this->capabilities))
229                         array_push($exts, 'imap4flags');
230                     else
231                         array_push($exts, 'imapflags');
232
233                     $script .= "\t".$action['type']." "
234                         . self::escape_string($action['target']) . ";\n";
235                     break;
236
237                 case 'keep':
238                 case 'discard':
239                 case 'stop':
240                     $script .= "\t" . $action['type'] .";\n";
241                     break;
242
243                 case 'vacation':
244                     array_push($exts, 'vacation');
245                     $script .= "\tvacation";
246                     if (!empty($action['days']))
247                         $script .= " :days " . $action['days'];
248                     if (!empty($action['addresses']))
249                         $script .= " :addresses " . self::escape_string($action['addresses']);
250                     if (!empty($action['subject']))
251                         $script .= " :subject " . self::escape_string($action['subject']);
252                     if (!empty($action['handle']))
253                         $script .= " :handle " . self::escape_string($action['handle']);
254                     if (!empty($action['from']))
255                         $script .= " :from " . self::escape_string($action['from']);
256                     if (!empty($action['mime']))
257                         $script .= " :mime";
258                     $script .= " " . self::escape_string($action['reason']) . ";\n";
259                     break;
260                 }
261             }
262
263             $script .= "}\n";
264             $idx++;
265         }
266
267         // requires
268         if (!empty($exts))
269             $script = 'require ["' . implode('","', array_unique($exts)) . "\"];\n" . $script;
270
271         return $script;
272     }
273
274     /**
275      * Returns script object
276      *
277      */
278     public function as_array()
279     {
280         return $this->content;
281     }
282
283     /**
284      * Returns array of supported extensions
285      *
286      */
287     public function get_extensions()
288     {
289         return array_values($this->supported);
290     }
291
292     /**
293      * Converts text script to rules array
294      *
295      * @param string Text script
296      */
297     private function _parse_text($script)
298     {
299         $i = 0;
300         $content = array();
301
302         // tokenize rules
303         if ($tokens = preg_split('/(# rule:\[.*\])\r?\n/', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
304             foreach($tokens as $token) {
305                 if (preg_match('/^# rule:\[(.*)\]/', $token, $matches)) {
306                     $content[$i]['name'] = $matches[1];
307                 }
308                 else if (isset($content[$i]['name']) && sizeof($content[$i]) == 1) {
309                     if ($rule = $this->_tokenize_rule($token)) {
310                         $content[$i] = array_merge($content[$i], $rule);
311                         $i++;
312                     }
313                     else // unknown rule format
314                         unset($content[$i]);
315                 }
316             }
317         }
318
319         return $content;
320     }
321
322     /**
323      * Convert text script fragment to rule object
324      *
325      * @param string Text rule
326      */
327     private function _tokenize_rule($content)
328     {
329         $cond = strtolower(self::tokenize($content, 1));
330
331         if ($cond != 'if' && $cond != 'elsif' && $cond != 'else') {
332             return null;
333         }
334
335         $disabled = false;
336         $join     = false;
337
338         // disabled rule (false + comment): if false # .....
339         if (preg_match('/^\s*false\s+#/i', $content)) {
340             $content = preg_replace('/^\s*false\s+#\s*/i', '', $content);
341             $disabled = true;
342         }
343
344         while (strlen($content)) {
345             $tokens = self::tokenize($content, true);
346             $separator = array_pop($tokens);
347
348             if (!empty($tokens)) {
349                 $token = array_shift($tokens);
350             }
351             else {
352                 $token = $separator;
353             }
354
355             $token = strtolower($token);
356
357             if ($token == 'not') {
358                 $not = true;
359                 $token = strtolower(array_shift($tokens));
360             }
361             else {
362                 $not = false;
363             }
364
365             switch ($token) {
366             case 'allof':
367                 $join = true;
368                 break;
369             case 'anyof':
370                 break;
371
372             case 'size':
373                 $size = array('test' => 'size', 'not'  => $not);
374                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
375                     if (!is_array($tokens[$i])
376                         && preg_match('/^:(under|over)$/i', $tokens[$i])
377                     ) {
378                         $size['type'] = strtolower(substr($tokens[$i], 1));
379                     }
380                     else {
381                         $size['arg'] = $tokens[$i];
382                     }
383                 }
384
385                 $tests[] = $size;
386                 break;
387
388             case 'header':
389                 $header = array('test' => 'header', 'not' => $not, 'arg1' => '', 'arg2' => '');
390                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
391                     if (!is_array($tokens[$i]) && preg_match('/^:comparator$/i', $tokens[$i])) {
392                         $i++;
393                     }
394                     else if (!is_array($tokens[$i]) && preg_match('/^:(count|value)$/i', $tokens[$i])) {
395                         $header['type'] = strtolower(substr($tokens[$i], 1)) . '-' . $tokens[++$i];
396                     }
397                     else if (!is_array($tokens[$i]) && preg_match('/^:(is|contains|matches|regex)$/i', $tokens[$i])) {
398                         $header['type'] = strtolower(substr($tokens[$i], 1));
399                     }
400                     else {
401                         $header['arg1'] = $header['arg2'];
402                         $header['arg2'] = $tokens[$i];
403                     }
404                 }
405
406                 $tests[] = $header;
407                 break;
408
409             case 'exists':
410                 $tests[] = array('test' => 'exists', 'not'  => $not,
411                     'arg'  => array_pop($tokens));
412                 break;
413
414             case 'true':
415                 $tests[] = array('test' => 'true', 'not'  => $not);
416                 break;
417
418             case 'false':
419                 $tests[] = array('test' => 'true', 'not'  => !$not);
420                 break;
421             }
422
423             // goto actions...
424             if ($separator == '{') {
425                 break;
426             }
427         }
428
429         // ...and actions block
430         if ($tests) {
431             $actions = $this->_parse_actions($content);
432         }
433
434         if ($tests && $actions) {
435             $result = array(
436                 'type'     => $cond,
437                 'tests'    => $tests,
438                 'actions'  => $actions,
439                 'join'     => $join,
440                 'disabled' => $disabled,
441             );
442         }
443
444         return $result;
445     }
446
447     /**
448      * Parse body of actions section
449      *
450      * @param string Text body
451      * @return array Array of parsed action type/target pairs
452      */
453     private function _parse_actions($content)
454     {
455         $result = null;
456
457         while (strlen($content)) {
458             $tokens = self::tokenize($content, true);
459             $separator = array_pop($tokens);
460
461             if (!empty($tokens)) {
462                 $token = array_shift($tokens);
463             }
464             else {
465                 $token = $separator;
466             }
467
468             switch ($token) {
469             case 'discard':
470             case 'keep':
471             case 'stop':
472                 $result[] = array('type' => $token);
473                 break;
474
475             case 'fileinto':
476             case 'redirect':
477                 $copy   = false;
478                 $target = '';
479
480                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
481                     if (strtolower($tokens[$i]) == ':copy') {
482                         $copy = true;
483                     }
484                     else {
485                         $target = $tokens[$i];
486                     }
487                 }
488
489                 $result[] = array('type' => $token, 'copy' => $copy,
490                     'target' => $target);
491                 break;
492
493             case 'reject':
494             case 'ereject':
495                 $result[] = array('type' => $token, 'target' => array_pop($tokens));
496                 break;
497
498             case 'vacation':
499                 $vacation = array('type' => 'vacation', 'reason' => array_pop($tokens));
500
501                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
502                     $tok = strtolower($tokens[$i]);
503                     if ($tok == ':days') {
504                         $vacation['days'] = $tokens[++$i];
505                     }
506                     else if ($tok == ':subject') {
507                         $vacation['subject'] = $tokens[++$i];
508                     }
509                     else if ($tok == ':addresses') {
510                         $vacation['addresses'] = $tokens[++$i];
511                     }
512                     else if ($tok == ':handle') {
513                         $vacation['handle'] = $tokens[++$i];
514                     }
515                     else if ($tok == ':from') {
516                         $vacation['from'] = $tokens[++$i];
517                     }
518                     else if ($tok == ':mime') {
519                         $vacation['mime'] = true;
520                     }
521                 }
522
523                 $result[] = $vacation;
524                 break;
525
526             case 'setflag':
527             case 'addflag':
528             case 'removeflag':
529                 $result[] = array('type' => $token,
530                     // Flags list: last token (skip optional variable)
531                     'target' => $tokens[count($tokens)-1]
532                 );
533                 break;
534             }
535         }
536
537         return $result;
538     }
539
540     /**
541      * Escape special chars into quoted string value or multi-line string
542      * or list of strings
543      *
544      * @param string $str Text or array (list) of strings
545      *
546      * @return string Result text
547      */
548     static function escape_string($str)
549     {
550         if (is_array($str) && count($str) > 1) {
551             foreach($str as $idx => $val)
552                 $str[$idx] = self::escape_string($val);
553
554             return '[' . implode(',', $str) . ']';
555         }
556         else if (is_array($str)) {
557             $str = array_pop($str);
558         }
559
560         // multi-line string
561         if (preg_match('/[\r\n\0]/', $str) || strlen($str) > 1024) {
562             return sprintf("text:\n%s\n.\n", self::escape_multiline_string($str));
563         }
564         // quoted-string
565         else {
566             return '"' . addcslashes($str, '\\"') . '"';
567         }
568     }
569
570     /**
571      * Escape special chars in multi-line string value
572      *
573      * @param string $str Text
574      *
575      * @return string Text
576      */
577     static function escape_multiline_string($str)
578     {
579         $str = preg_split('/(\r?\n)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE);
580
581         foreach ($str as $idx => $line) {
582             // dot-stuffing
583             if (isset($line[0]) && $line[0] == '.') {
584                 $str[$idx] = '.' . $line;
585             }
586         }
587
588         return implode($str);
589     }
590
591     /**
592      * Splits script into string tokens
593      *
594      * @param string &$str    The script
595      * @param mixed  $num     Number of tokens to return, 0 for all
596      *                        or True for all tokens until separator is found.
597      *                        Separator will be returned as last token.
598      * @param int    $in_list Enable to called recursively inside a list
599      *
600      * @return mixed Tokens array or string if $num=1
601      */
602     static function tokenize(&$str, $num=0, $in_list=false)
603     {
604         $result = array();
605
606         // remove spaces from the beginning of the string
607         while (($str = ltrim($str)) !== ''
608             && (!$num || $num === true || count($result) < $num)
609         ) {
610             switch ($str[0]) {
611
612             // Quoted string
613             case '"':
614                 $len = strlen($str);
615
616                 for ($pos=1; $pos<$len; $pos++) {
617                     if ($str[$pos] == '"') {
618                         break;
619                     }
620                     if ($str[$pos] == "\\") {
621                         if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
622                             $pos++;
623                         }
624                     }
625                 }
626                 if ($str[$pos] != '"') {
627                     // error
628                 }
629                 // we need to strip slashes for a quoted string
630                 $result[] = stripslashes(substr($str, 1, $pos - 1));
631                 $str      = substr($str, $pos + 1);
632                 break;
633
634             // Parenthesized list
635             case '[':
636                 $str = substr($str, 1);
637                 $result[] = self::tokenize($str, 0, true);
638                 break;
639             case ']':
640                 $str = substr($str, 1);
641                 return $result;
642                 break;
643
644             // list/test separator
645             case ',':
646             // command separator
647             case ';':
648             // block/tests-list
649             case '(':
650             case ')':
651             case '{':
652             case '}':
653                 $sep = $str[0];
654                 $str = substr($str, 1);
655                 if ($num === true) {
656                     $result[] = $sep;
657                     break 2; 
658                 }
659                 break;
660
661             // bracket-comment
662             case '/':
663                 if ($str[1] == '*') {
664                     if ($end_pos = strpos($str, '*/')) {
665                         $str = substr($str, $end_pos + 2);
666                     }
667                     else {
668                         // error
669                         $str = '';
670                     }
671                 }
672                 break;
673
674             // hash-comment
675             case '#':
676                 if ($lf_pos = strpos($str, "\n")) {
677                     $str = substr($str, $lf_pos);
678                     break;
679                 }
680                 else {
681                     $str = '';
682                 }
683
684             // String atom
685             default:
686                 // empty or one character
687                 if ($str === '') {
688                     break 2;
689                 }
690                 if (strlen($str) < 2) {
691                     $result[] = $str;
692                     $str = '';
693                     break;
694                 }
695
696                 // tag/identifier/number
697                 if (preg_match('/^([a-z0-9:_]+)/i', $str, $m)) {
698                     $str = substr($str, strlen($m[1]));
699
700                     if ($m[1] != 'text:') {
701                         $result[] = $m[1];
702                     }
703                     // multiline string
704                     else {
705                         // possible hash-comment after "text:"
706                         if (preg_match('/^( |\t)*(#[^\n]+)?\n/', $str, $m)) {
707                             $str = substr($str, strlen($m[0]));
708                         }
709                         // get text until alone dot in a line
710                         if (preg_match('/^(.*)\r?\n\.\r?\n/sU', $str, $m)) {
711                             $text = $m[1];
712                             // remove dot-stuffing
713                             $text = str_replace("\n..", "\n.", $text);
714                             $str = substr($str, strlen($m[0]));
715                         }
716                         else {
717                             $text = '';
718                         }
719
720                         $result[] = $text;
721                     }
722                 }
723
724                 break;
725             }
726         }
727
728         return $num === 1 ? (isset($result[0]) ? $result[0] : null) : $result;
729     }
730
731 }