Aleksander Machniak
2016-04-06 80cb362b8001ead4289c22dffca2ef0a1bd5b3f2
commit | author | age
48e9c1 1 <?php
T 2
3 /**
4  *  Class for operations on Sieve scripts
5  *
6  * Copyright (C) 2008-2011, The Roundcube Dev Team
7  * Copyright (C) 2011, Kolab Systems AG
8  *
07c6c6 9  * This program is free software: you can redistribute it and/or modify
TB 10  * it under the terms of the GNU General Public License as published by
11  * the Free Software Foundation, either version 3 of the License, or
12  * (at your option) any later version.
48e9c1 13  *
T 14  * This program is distributed in the hope that it will be useful,
15  * but WITHOUT ANY WARRANTY; without even the implied warranty of
16  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17  * GNU General Public License for more details.
18  *
07c6c6 19  * You should have received a copy of the GNU General Public License
TB 20  * along with this program. If not, see http://www.gnu.org/licenses/.
48e9c1 21  */
T 22
23 class rcube_sieve_script
24 {
25     public $content = array();      // script rules array
26
27     private $vars = array();        // "global" variables
28     private $prefix = '';           // script header (comments)
29     private $supported = array(     // Sieve extensions supported by class
ed837a 30         'body',                     // RFC5173
48e9c1 31         'copy',                     // RFC3894
ed837a 32         'date',                     // RFC5260
AM 33         'enotify',                  // RFC5435
34         'envelope',                 // RFC5228
35         'ereject',                  // RFC5429
36         'fileinto',                 // RFC5228
48e9c1 37         'imapflags',                // draft-melnikov-sieve-imapflags-06
T 38         'imap4flags',               // RFC5232
39         'include',                  // draft-ietf-sieve-include-12
ed837a 40         'index',                    // RFC5260
f22de5 41         'notify',                   // draft-martin-sieve-notify-01,
ed837a 42         'regex',                    // draft-ietf-sieve-regex-01
AM 43         'reject',                   // RFC5429
44         'relational',               // RFC3431
45         'subaddress',               // RFC5233
46         'vacation',                 // RFC5230
47         'vacation-seconds',         // RFC6131
48         'variables',                // RFC5229
49         // @TODO: spamtest+virustest, mailbox
48e9c1 50     );
T 51
52     /**
53      * Object constructor
54      *
55      * @param  string  Script's text content
56      * @param  array   List of capabilities supported by server
57      */
58     public function __construct($script, $capabilities=array())
59     {
60         $capabilities = array_map('strtolower', (array) $capabilities);
61
62         // disable features by server capabilities
63         if (!empty($capabilities)) {
64             foreach ($this->supported as $idx => $ext) {
65                 if (!in_array($ext, $capabilities)) {
66                     unset($this->supported[$idx]);
67                 }
68             }
69         }
70
71         // Parse text content of the script
72         $this->_parse_text($script);
73     }
74
75     /**
76      * Adds rule to the script (at the end)
77      *
78      * @param string Rule name
79      * @param array  Rule content (as array)
80      *
81      * @return int The index of the new rule
82      */
83     public function add_rule($content)
84     {
85         // TODO: check this->supported
86         array_push($this->content, $content);
87         return sizeof($this->content)-1;
88     }
89
90     public function delete_rule($index)
91     {
92         if(isset($this->content[$index])) {
93             unset($this->content[$index]);
94             return true;
95         }
96         return false;
97     }
98
99     public function size()
100     {
101         return sizeof($this->content);
102     }
103
104     public function update_rule($index, $content)
105     {
106         // TODO: check this->supported
107         if ($this->content[$index]) {
108             $this->content[$index] = $content;
109             return $index;
110         }
111         return false;
112     }
113
114     /**
115      * Sets "global" variable
116      *
117      * @param string $name  Variable name
118      * @param string $value Variable value
119      * @param array  $mods  Variable modifiers
120      */
121     public function set_var($name, $value, $mods = array())
122     {
123         // Check if variable exists
124         for ($i=0, $len=count($this->vars); $i<$len; $i++) {
125             if ($this->vars[$i]['name'] == $name) {
126                 break;
127             }
128         }
129
130         $var = array_merge($mods, array('name' => $name, 'value' => $value));
131         $this->vars[$i] = $var;
132     }
133
134     /**
135      * Unsets "global" variable
136      *
137      * @param string $name  Variable name
138      */
139     public function unset_var($name)
140     {
141         // Check if variable exists
142         foreach ($this->vars as $idx => $var) {
143             if ($var['name'] == $name) {
144                 unset($this->vars[$idx]);
145                 break;
146             }
147         }
148     }
149
150     /**
151      * Gets the value of  "global" variable
152      *
153      * @param string $name  Variable name
154      *
155      * @return string Variable value
156      */
157     public function get_var($name)
158     {
159         // Check if variable exists
160         for ($i=0, $len=count($this->vars); $i<$len; $i++) {
161             if ($this->vars[$i]['name'] == $name) {
162                 return $this->vars[$i]['name'];
163             }
164         }
165     }
166
167     /**
168      * Sets script header content
169      *
170      * @param string $text  Header content
171      */
172     public function set_prefix($text)
173     {
174         $this->prefix = $text;
175     }
176
177     /**
178      * Returns script as text
179      */
180     public function as_text()
181     {
182         $output = '';
183         $exts   = array();
184         $idx    = 0;
185
186         if (!empty($this->vars)) {
187             if (in_array('variables', (array)$this->supported)) {
188                 $has_vars = true;
189                 array_push($exts, 'variables');
190             }
191             foreach ($this->vars as $var) {
192                 if (empty($has_vars)) {
193                     // 'variables' extension not supported, put vars in comments
194                     $output .= sprintf("# %s %s\n", $var['name'], $var['value']);
195                 }
196                 else {
197                     $output .= 'set ';
198                     foreach (array_diff(array_keys($var), array('name', 'value')) as $opt) {
199                         $output .= ":$opt ";
200                     }
201                     $output .= self::escape_string($var['name']) . ' ' . self::escape_string($var['value']) . ";\n";
202                 }
203             }
204         }
eb1ee0 205
AM 206         $imapflags = in_array('imap4flags', $this->supported) ? 'imap4flags' : 'imapflags';
207         $notify    = in_array('enotify', $this->supported) ? 'enotify' : 'notify';
48e9c1 208
T 209         // rules
210         foreach ($this->content as $rule) {
211             $script    = '';
212             $tests     = array();
213             $i         = 0;
214
215             // header
216             if (!empty($rule['name']) && strlen($rule['name'])) {
217                 $script .= '# rule:[' . $rule['name'] . "]\n";
218             }
219
220             // constraints expressions
221             if (!empty($rule['tests'])) {
222                 foreach ($rule['tests'] as $test) {
223                     $tests[$i] = '';
224                     switch ($test['test']) {
225                     case 'size':
226                         $tests[$i] .= ($test['not'] ? 'not ' : '');
227                         $tests[$i] .= 'size :' . ($test['type']=='under' ? 'under ' : 'over ') . $test['arg'];
228                         break;
229
230                     case 'true':
231                         $tests[$i] .= ($test['not'] ? 'false' : 'true');
232                         break;
233
234                     case 'exists':
235                         $tests[$i] .= ($test['not'] ? 'not ' : '');
236                         $tests[$i] .= 'exists ' . self::escape_string($test['arg']);
237                         break;
238
239                     case 'header':
240                         $tests[$i] .= ($test['not'] ? 'not ' : '');
241                         $tests[$i] .= 'header';
242
ed837a 243                         $this->add_index($test, $tests[$i], $exts);
AM 244                         $this->add_operator($test, $tests[$i], $exts);
48e9c1 245
T 246                         $tests[$i] .= ' ' . self::escape_string($test['arg1']);
247                         $tests[$i] .= ' ' . self::escape_string($test['arg2']);
248                         break;
249
250                     case 'address':
251                     case 'envelope':
252                         if ($test['test'] == 'envelope') {
253                             array_push($exts, 'envelope');
254                         }
255
256                         $tests[$i] .= ($test['not'] ? 'not ' : '');
257                         $tests[$i] .= $test['test'];
258
ed837a 259                         if ($test['test'] != 'envelope') {
AM 260                             $this->add_index($test, $tests[$i], $exts);
261                         }
262
b2c586 263                         // :all address-part is optional, skip it
AM 264                         if (!empty($test['part']) && $test['part'] != 'all') {
48e9c1 265                             $tests[$i] .= ' :' . $test['part'];
T 266                             if ($test['part'] == 'user' || $test['part'] == 'detail') {
267                                 array_push($exts, 'subaddress');
268                             }
269                         }
270
ed837a 271                         $this->add_operator($test, $tests[$i], $exts);
48e9c1 272
T 273                         $tests[$i] .= ' ' . self::escape_string($test['arg1']);
274                         $tests[$i] .= ' ' . self::escape_string($test['arg2']);
275                         break;
276
277                     case 'body':
278                         array_push($exts, 'body');
279
280                         $tests[$i] .= ($test['not'] ? 'not ' : '') . 'body';
281
282                         if (!empty($test['part'])) {
283                             $tests[$i] .= ' :' . $test['part'];
284
285                             if (!empty($test['content']) && $test['part'] == 'content') {
286                                 $tests[$i] .= ' ' . self::escape_string($test['content']);
287                             }
288                         }
289
ed837a 290                         $this->add_operator($test, $tests[$i], $exts);
48e9c1 291
T 292                         $tests[$i] .= ' ' . self::escape_string($test['arg']);
ed837a 293                         break;
AM 294
295                     case 'date':
296                     case 'currentdate':
297                         array_push($exts, 'date');
298
299                         $tests[$i] .= ($test['not'] ? 'not ' : '') . $test['test'];
300
301                         $this->add_index($test, $tests[$i], $exts);
302
303                         if (!empty($test['originalzone']) && $test['test'] == 'date') {
304                             $tests[$i] .= ' :originalzone';
305                         }
306                         else if (!empty($test['zone'])) {
307                             $tests[$i] .= ' :zone ' . self::escape_string($test['zone']);
308                         }
309
310                         $this->add_operator($test, $tests[$i], $exts);
311
312                         if ($test['test'] == 'date') {
313                             $tests[$i] .= ' ' . self::escape_string($test['header']);
314                         }
315
316                         $tests[$i] .= ' ' . self::escape_string($test['part']);
317                         $tests[$i] .= ' ' . self::escape_string($test['arg']);
318
48e9c1 319                         break;
T 320                     }
321                     $i++;
322                 }
323             }
324
325             // disabled rule: if false #....
326             if (!empty($tests)) {
327                 $script .= 'if ' . ($rule['disabled'] ? 'false # ' : '');
328
329                 if (count($tests) > 1) {
330                     $tests_str = implode(', ', $tests);
331                 }
332                 else {
333                     $tests_str = $tests[0];
334                 }
335
336                 if ($rule['join'] || count($tests) > 1) {
337                     $script .= sprintf('%s (%s)', $rule['join'] ? 'allof' : 'anyof', $tests_str);
338                 }
339                 else {
340                     $script .= $tests_str;
341                 }
342                 $script .= "\n{\n";
343             }
344
345             // action(s)
346             if (!empty($rule['actions'])) {
347                 foreach ($rule['actions'] as $action) {
348                     $action_script = '';
349
350                     switch ($action['type']) {
351
352                     case 'fileinto':
353                         array_push($exts, 'fileinto');
354                         $action_script .= 'fileinto ';
355                         if ($action['copy']) {
356                             $action_script .= ':copy ';
357                             array_push($exts, 'copy');
358                         }
359                         $action_script .= self::escape_string($action['target']);
360                         break;
361
362                     case 'redirect':
363                         $action_script .= 'redirect ';
364                         if ($action['copy']) {
365                             $action_script .= ':copy ';
366                             array_push($exts, 'copy');
367                         }
368                         $action_script .= self::escape_string($action['target']);
369                         break;
370
371                     case 'reject':
372                     case 'ereject':
373                         array_push($exts, $action['type']);
374                         $action_script .= $action['type'].' '
375                             . self::escape_string($action['target']);
376                         break;
377
378                     case 'addflag':
379                     case 'setflag':
380                     case 'removeflag':
eb1ee0 381                         array_push($exts, $imapflags);
48e9c1 382                         $action_script .= $action['type'].' '
T 383                             . self::escape_string($action['target']);
384                         break;
385
386                     case 'keep':
387                     case 'discard':
388                     case 'stop':
389                         $action_script .= $action['type'];
390                         break;
391
392                     case 'include':
393                         array_push($exts, 'include');
394                         $action_script .= 'include ';
395                         foreach (array_diff(array_keys($action), array('target', 'type')) as $opt) {
396                             $action_script .= ":$opt ";
397                         }
398                         $action_script .= self::escape_string($action['target']);
399                         break;
400
401                     case 'set':
402                         array_push($exts, 'variables');
403                         $action_script .= 'set ';
404                         foreach (array_diff(array_keys($action), array('name', 'value', 'type')) as $opt) {
405                             $action_script .= ":$opt ";
406                         }
407                         $action_script .= self::escape_string($action['name']) . ' ' . self::escape_string($action['value']);
408                         break;
409
2ac00a 410                     case 'notify':
eb1ee0 411                         array_push($exts, $notify);
2ac00a 412                         $action_script .= 'notify';
eb1ee0 413
f22de5 414                         $method = $action['method'];
AM 415                         unset($action['method']);
416                         $action['options'] = (array) $action['options'];
417
418                         // Here we support draft-martin-sieve-notify-01 used by Cyrus
cd97f0 419                         if ($notify == 'notify') {
AM 420                             switch ($action['importance']) {
421                                 case 1: $action_script .= " :high"; break;
f22de5 422                                 //case 2: $action_script .= " :normal"; break;
cd97f0 423                                 case 3: $action_script .= " :low"; break;
AM 424                             }
f22de5 425
AM 426                             // Old-draft way: :method "mailto" :options "email@address"
427                             if (!empty($method)) {
428                                 $parts = explode(':', $method, 2);
429                                 $action['method'] = $parts[0];
430                                 array_unshift($action['options'], $parts[1]);
431                             }
432
cd97f0 433                             unset($action['importance']);
f22de5 434                             unset($action['from']);
AM 435                             unset($method);
cd97f0 436                         }
AM 437
f22de5 438                         foreach (array('id', 'importance', 'method', 'options', 'from', 'message') as $n_tag) {
2ac00a 439                             if (!empty($action[$n_tag])) {
PS 440                                 $action_script .= " :$n_tag " . self::escape_string($action[$n_tag]);
441                             }
442                         }
cd97f0 443
eb1ee0 444                         if (!empty($method)) {
f22de5 445                             $action_script .= ' ' . self::escape_string($method);
eb1ee0 446                         }
AM 447
2ac00a 448                         break;
PS 449
48e9c1 450                     case 'vacation':
T 451                         array_push($exts, 'vacation');
452                         $action_script .= 'vacation';
b6fa7d 453                         if (isset($action['seconds'])) {
AM 454                             array_push($exts, 'vacation-seconds');
455                             $action_script .= " :seconds " . intval($action['seconds']);
456                         }
457                         else if (!empty($action['days'])) {
458                             $action_script .= " :days " . intval($action['days']);
459                         }
48e9c1 460                         if (!empty($action['addresses']))
T 461                             $action_script .= " :addresses " . self::escape_string($action['addresses']);
462                         if (!empty($action['subject']))
463                             $action_script .= " :subject " . self::escape_string($action['subject']);
464                         if (!empty($action['handle']))
465                             $action_script .= " :handle " . self::escape_string($action['handle']);
466                         if (!empty($action['from']))
467                             $action_script .= " :from " . self::escape_string($action['from']);
468                         if (!empty($action['mime']))
469                             $action_script .= " :mime";
470                         $action_script .= " " . self::escape_string($action['reason']);
471                         break;
472                     }
473
474                     if ($action_script) {
475                         $script .= !empty($tests) ? "\t" : '';
476                         $script .= $action_script . ";\n";
477                     }
478                 }
479             }
480
481             if ($script) {
482                 $output .= $script . (!empty($tests) ? "}\n" : '');
483                 $idx++;
484             }
485         }
486
487         // requires
b6fa7d 488         if (!empty($exts)) {
AM 489             $exts = array_unique($exts);
490
491             if (in_array('vacation-seconds', $exts) && ($key = array_search('vacation', $exts)) !== false) {
492                 unset($exts[$key]);
493             }
494
ed837a 495             sort($exts); // for convenience use always the same order
AM 496
b6fa7d 497             $output = 'require ["' . implode('","', $exts) . "\"];\n" . $output;
AM 498         }
48e9c1 499
T 500         if (!empty($this->prefix)) {
501             $output = $this->prefix . "\n\n" . $output;
502         }
503
504         return $output;
505     }
506
507     /**
508      * Returns script object
509      *
510      */
511     public function as_array()
512     {
513         return $this->content;
514     }
515
516     /**
517      * Returns array of supported extensions
518      *
519      */
520     public function get_extensions()
521     {
522         return array_values($this->supported);
523     }
524
525     /**
526      * Converts text script to rules array
527      *
528      * @param string Text script
529      */
530     private function _parse_text($script)
531     {
80cb36 532         $prefix   = '';
AM 533         $options  = array();
534         $position = 0;
535         $length   = strlen($script);
48e9c1 536
80cb36 537         while ($position < $length) {
AM 538             // skip whitespace chars
539             $position = self::ltrim_position($script, $position);
540             $rulename = '';
48e9c1 541
T 542             // Comments
80cb36 543             while ($script[$position] === '#') {
AM 544                 $endl = strpos($script, "\n", $position) ?: $length;
545                 $line = substr($script, $position, $endl - $position);
48e9c1 546
T 547                 // Roundcube format
548                 if (preg_match('/^# rule:\[(.*)\]/', $line, $matches)) {
549                     $rulename = $matches[1];
550                 }
551                 // KEP:14 variables
552                 else if (preg_match('/^# (EDITOR|EDITOR_VERSION) (.+)$/', $line, $matches)) {
553                     $this->set_var($matches[1], $matches[2]);
554                 }
555                 // Horde-Ingo format
556                 else if (!empty($options['format']) && $options['format'] == 'INGO'
557                     && preg_match('/^# (.*)/', $line, $matches)
558                 ) {
559                     $rulename = $matches[1];
560                 }
561                 else if (empty($options['prefix'])) {
562                     $prefix .= $line . "\n";
563                 }
564
80cb36 565                 $position = $endl + 1;
48e9c1 566             }
T 567
568             // handle script header
569             if (empty($options['prefix'])) {
570                 $options['prefix'] = true;
571                 if ($prefix && strpos($prefix, 'horde.org/ingo')) {
572                     $options['format'] = 'INGO';
573                 }
574             }
575
576             // Control structures/blocks
80cb36 577             if (preg_match('/^(if|else|elsif)/i', substr($script, $position, 5))) {
AM 578                 $rule = $this->_tokenize_rule($script, $position);
48e9c1 579                 if (strlen($rulename) && !empty($rule)) {
T 580                     $rule['name'] = $rulename;
581                 }
582             }
583             // Simple commands
584             else {
80cb36 585                 $rule = $this->_parse_actions($script, $position, ';');
48e9c1 586                 if (!empty($rule[0]) && is_array($rule)) {
T 587                     // set "global" variables
588                     if ($rule[0]['type'] == 'set') {
589                         unset($rule[0]['type']);
590                         $this->vars[] = $rule[0];
022f51 591                         unset($rule);
48e9c1 592                     }
T 593                     else {
594                         $rule = array('actions' => $rule);
595                     }
596                 }
597             }
598
599             if (!empty($rule)) {
600                 $this->content[] = $rule;
601             }
602         }
603
604         if (!empty($prefix)) {
605             $this->prefix = trim($prefix);
606         }
607     }
608
609     /**
610      * Convert text script fragment to rule object
611      *
80cb36 612      * @param string $content   The whole script content
AM 613      * @param int    &$position Start position in the script
48e9c1 614      *
T 615      * @return array Rule data
616      */
80cb36 617     private function _tokenize_rule($content, &$position)
48e9c1 618     {
80cb36 619         $cond = strtolower(self::tokenize($content, 1, $position));
48e9c1 620         if ($cond != 'if' && $cond != 'elsif' && $cond != 'else') {
T 621             return null;
622         }
623
624         $disabled = false;
625         $join     = false;
6af79f 626         $join_not = false;
80cb36 627         $length   = strlen($content);
48e9c1 628
T 629         // disabled rule (false + comment): if false # .....
80cb36 630         if (preg_match('/^\s*false\s+#\s*/i', substr($content, $position, 20), $m)) {
AM 631             $position += strlen($m[0]);
48e9c1 632             $disabled = true;
T 633         }
634
80cb36 635         while ($position < $length) {
AM 636             $tokens    = self::tokenize($content, true, $position);
48e9c1 637             $separator = array_pop($tokens);
T 638
639             if (!empty($tokens)) {
640                 $token = array_shift($tokens);
641             }
642             else {
643                 $token = $separator;
644             }
645
646             $token = strtolower($token);
647
648             if ($token == 'not') {
649                 $not = true;
650                 $token = strtolower(array_shift($tokens));
651             }
652             else {
653                 $not = false;
654             }
655
6af79f 656             // we support "not allof" as a negation of allof sub-tests
AM 657             if ($join_not) {
658                 $not = !$not;
659             }
660
48e9c1 661             switch ($token) {
T 662             case 'allof':
6af79f 663                 $join     = true;
AM 664                 $join_not = $not;
48e9c1 665                 break;
6af79f 666
48e9c1 667             case 'anyof':
T 668                 break;
669
670             case 'size':
6af79f 671                 $test = array('test' => 'size', 'not' => $not);
ed837a 672
AM 673                 $test['arg'] = array_pop($tokens);
674
48e9c1 675                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
T 676                     if (!is_array($tokens[$i])
677                         && preg_match('/^:(under|over)$/i', $tokens[$i])
678                     ) {
ed837a 679                         $test['type'] = strtolower(substr($tokens[$i], 1));
48e9c1 680                     }
T 681                 }
682
ed837a 683                 $tests[] = $test;
48e9c1 684                 break;
T 685
686             case 'header':
687             case 'address':
688             case 'envelope':
ed837a 689                 $test = array('test' => $token, 'not' => $not);
AM 690
691                 $test['arg2'] = array_pop($tokens);
692                 $test['arg1'] = array_pop($tokens);
693
694                 $test += $this->test_tokens($tokens);
695
696                 if ($token != 'header' && !empty($tokens)) {
697                     for ($i=0, $len=count($tokens); $i<$len; $i++) {
698                         if (!is_array($tokens[$i]) && preg_match('/^:(localpart|domain|all|user|detail)$/i', $tokens[$i])) {
699                             $test['part'] = strtolower(substr($tokens[$i], 1));
700                         }
48e9c1 701                     }
T 702                 }
703
ed837a 704                 $tests[] = $test;
48e9c1 705                 break;
T 706
707             case 'body':
ed837a 708                 $test = array('test' => 'body', 'not' => $not);
48e9c1 709
ed837a 710                 $test['arg'] = array_pop($tokens);
AM 711
712                 $test += $this->test_tokens($tokens);
713
714                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
715                     if (!is_array($tokens[$i]) && preg_match('/^:(raw|content|text)$/i', $tokens[$i])) {
716                         $test['part'] = strtolower(substr($tokens[$i], 1));
717
718                         if ($test['part'] == 'content') {
719                             $test['content'] = $tokens[++$i];
48e9c1 720                         }
T 721                     }
722                 }
723
ed837a 724                 $tests[] = $test;
AM 725                 break;
726
727             case 'date':
728             case 'currentdate':
729                 $test = array('test' => $token, 'not' => $not);
730
731                 $test['arg']  = array_pop($tokens);
732                 $test['part'] = array_pop($tokens);
733
734                 if ($token == 'date') {
735                     $test['header']  = array_pop($tokens);
736                 }
737
738                 $test += $this->test_tokens($tokens);
739
740                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
741                     if (!is_array($tokens[$i]) && preg_match('/^:zone$/i', $tokens[$i])) {
742                         $test['zone'] = $tokens[++$i];
743                     }
744                     else if (!is_array($tokens[$i]) && preg_match('/^:originalzone$/i', $tokens[$i])) {
745                         $test['originalzone'] = true;
746                     }
747                 }
748
749                 $tests[] = $test;
48e9c1 750                 break;
T 751
752             case 'exists':
6af79f 753                 $tests[] = array('test' => 'exists', 'not' => $not,
48e9c1 754                     'arg'  => array_pop($tokens));
T 755                 break;
756
757             case 'true':
6af79f 758                 $tests[] = array('test' => 'true', 'not' => $not);
48e9c1 759                 break;
T 760
761             case 'false':
6af79f 762                 $tests[] = array('test' => 'true', 'not' => !$not);
48e9c1 763                 break;
T 764             }
765
766             // goto actions...
767             if ($separator == '{') {
768                 break;
769             }
770         }
771
772         // ...and actions block
80cb36 773         $actions = $this->_parse_actions($content, $position);
48e9c1 774
T 775         if ($tests && $actions) {
776             $result = array(
777                 'type'     => $cond,
778                 'tests'    => $tests,
779                 'actions'  => $actions,
780                 'join'     => $join,
781                 'disabled' => $disabled,
782             );
783         }
784
785         return $result;
786     }
787
788     /**
789      * Parse body of actions section
790      *
80cb36 791      * @param string $content   The whole script content
AM 792      * @param int    &$position Start position in the script
793      * @param string $end       End of text separator
48e9c1 794      *
T 795      * @return array Array of parsed action type/target pairs
796      */
80cb36 797     private function _parse_actions($content, &$position, $end = '}')
48e9c1 798     {
T 799         $result = null;
80cb36 800         $length = strlen($content);
48e9c1 801
80cb36 802         while ($position < $length) {
AM 803             $tokens    = self::tokenize($content, true, $position);
48e9c1 804             $separator = array_pop($tokens);
ed837a 805             $token     = !empty($tokens) ? array_shift($tokens) : $separator;
48e9c1 806
T 807             switch ($token) {
808             case 'discard':
809             case 'keep':
810             case 'stop':
811                 $result[] = array('type' => $token);
812                 break;
813
814             case 'fileinto':
815             case 'redirect':
ed837a 816                 $action  = array('type' => $token, 'target' => array_pop($tokens));
AM 817                 $args    = array('copy');
818                 $action += $this->action_arguments($tokens, $args);
48e9c1 819
ed837a 820                 $result[] = $action;
AM 821                 break;
48e9c1 822
ed837a 823             case 'vacation':
AM 824                 $action  = array('type' => 'vacation', 'reason' => array_pop($tokens));
825                 $args    = array('mime');
826                 $vargs   = array('seconds', 'days', 'addresses', 'subject', 'handle', 'from');
827                 $action += $this->action_arguments($tokens, $args, $vargs);
828
829                 $result[] = $action;
48e9c1 830                 break;
T 831
832             case 'reject':
833             case 'ereject':
834             case 'setflag':
835             case 'addflag':
836             case 'removeflag':
ed837a 837                 $result[] = array('type' => $token, 'target' => array_pop($tokens));
48e9c1 838                 break;
T 839
840             case 'include':
ed837a 841                 $action  = array('type' => 'include', 'target' => array_pop($tokens));
AM 842                 $args    = array('once', 'optional', 'global', 'personal');
843                 $action += $this->action_arguments($tokens, $args);
48e9c1 844
ed837a 845                 $result[] = $action;
48e9c1 846                 break;
T 847
848             case 'set':
ed837a 849                 $action  = array('type' => 'set', 'value' => array_pop($tokens), 'name' => array_pop($tokens));
AM 850                 $args    = array('lower', 'upper', 'lowerfirst', 'upperfirst', 'quotewildcard', 'length');
851                 $action += $this->action_arguments($tokens, $args);
48e9c1 852
ed837a 853                 $result[] = $action;
48e9c1 854                 break;
T 855
856             case 'require':
857                 // skip, will be build according to used commands
ed837a 858                 // $result[] = array('type' => 'require', 'target' => array_pop($tokens));
48e9c1 859                 break;
T 860
2ac00a 861             case 'notify':
ed837a 862                 $action     = array('type' => 'notify');
AM 863                 $priorities = array('high' => 1, 'normal' => 2, 'low' => 3);
f22de5 864                 $vargs      = array('from', 'id', 'importance', 'options', 'message', 'method');
ed837a 865                 $args       = array_keys($priorities);
AM 866                 $action    += $this->action_arguments($tokens, $args, $vargs);
2ac00a 867
f22de5 868                 // Here we'll convert draft-martin-sieve-notify-01 into RFC 5435
ed837a 869                 if (!isset($action['importance'])) {
AM 870                     foreach ($priorities as $key => $val) {
871                         if (isset($action[$key])) {
872                             $action['importance'] = $val;
873                             unset($action[$key]);
cd97f0 874                         }
2ac00a 875                     }
PS 876                 }
eb1ee0 877
f22de5 878                 $action['options'] = (array) $action['options'];
ed837a 879
f22de5 880                 // Old-draft way: :method "mailto" :options "email@address"
AM 881                 if (!empty($action['method']) && !empty($action['options'])) {
882                     $action['method'] .= ':' . array_shift($action['options']);
883                 }
884                 // unnamed parameter is a :method in enotify extension
885                 else if (!isset($action['method'])) {
886                     $action['method'] = array_pop($tokens);
2ac00a 887                 }
PS 888
ed837a 889                 $result[] = $action;
2ac00a 890                 break;
48e9c1 891             }
T 892
893             if ($separator == $end)
894                 break;
895         }
896
897         return $result;
898     }
899
900     /**
ed837a 901      * Add comparator to the test
48e9c1 902      */
T 903     private function add_comparator($test, &$out, &$exts)
904     {
905         if (empty($test['comparator'])) {
906             return;
907         }
908
909         if ($test['comparator'] == 'i;ascii-numeric') {
910             array_push($exts, 'relational');
911             array_push($exts, 'comparator-i;ascii-numeric');
912         }
913         else if (!in_array($test['comparator'], array('i;octet', 'i;ascii-casemap'))) {
914             array_push($exts, 'comparator-' . $test['comparator']);
915         }
916
917         // skip default comparator
918         if ($test['comparator'] != 'i;ascii-casemap') {
919             $out .= ' :comparator ' . self::escape_string($test['comparator']);
920         }
921     }
922
923     /**
ed837a 924      * Add index argument to the test
AM 925      */
926     private function add_index($test, &$out, &$exts)
927     {
928         if (!empty($test['index'])) {
929             array_push($exts, 'index');
930             $out .= ' :index ' . intval($test['index']) . ($test['last'] ? ' :last' : '');
931         }
932     }
933
934     /**
935      * Add operators to the test
936      */
937     private function add_operator($test, &$out, &$exts)
938     {
939         if (empty($test['type'])) {
940             return;
941         }
942
889c76 943         // relational operator
ed837a 944         if (preg_match('/^(value|count)-([gteqnl]{2})/', $test['type'], $m)) {
AM 945             array_push($exts, 'relational');
946
439fd7 947             $out .= ' :' . $m[1] . ' "' . $m[2] . '"';
ed837a 948         }
AM 949         else {
950             if ($test['type'] == 'regex') {
951                 array_push($exts, 'regex');
952             }
953
954             $out .= ' :' . $test['type'];
955         }
889c76 956
AM 957         $this->add_comparator($test, $out, $exts);
ed837a 958     }
AM 959
960     /**
961      * Extract test tokens
962      */
963     private function test_tokens(&$tokens)
964     {
965         $test   = array();
966         $result = array();
967
968         for ($i=0, $len=count($tokens); $i<$len; $i++) {
969             if (!is_array($tokens[$i]) && preg_match('/^:comparator$/i', $tokens[$i])) {
970                 $test['comparator'] = $tokens[++$i];
971             }
972             else if (!is_array($tokens[$i]) && preg_match('/^:(count|value)$/i', $tokens[$i])) {
973                 $test['type'] = strtolower(substr($tokens[$i], 1)) . '-' . $tokens[++$i];
974             }
975             else if (!is_array($tokens[$i]) && preg_match('/^:(is|contains|matches|regex)$/i', $tokens[$i])) {
976                 $test['type'] = strtolower(substr($tokens[$i], 1));
977             }
978             else if (!is_array($tokens[$i]) && preg_match('/^:index$/i', $tokens[$i])) {
979                 $test['index'] = intval($tokens[++$i]);
980                 if ($tokens[$i+1] && preg_match('/^:last$/i', $tokens[$i+1])) {
981                     $test['last'] = true;
982                     $i++;
983                 }
984            }
985            else {
986                $result[] = $tokens[$i];
987            }
988         }
989
990         $tokens = $result;
991
992         return $test;
993     }
994
995     /**
996      * Extract action arguments
997      */
998     private function action_arguments(&$tokens, $bool_args, $val_args = array())
999     {
1000         $action = array();
1001         $result = array();
1002
1003         for ($i=0, $len=count($tokens); $i<$len; $i++) {
1004             $tok = $tokens[$i];
1005             if (!is_array($tok) && $tok[0] == ':') {
1006                 $tok = strtolower(substr($tok, 1));
1007                 if (in_array($tok, $bool_args)) {
1008                     $action[$tok] = true;
1009                 }
1010                 else if (in_array($tok, $val_args)) {
1011                     $action[$tok] = $tokens[++$i];
1012                 }
1013                 else {
1014                     $result[] = $tok;
1015                 }
1016             }
1017             else {
1018                 $result[] = $tok;
1019             }
1020         }
1021
1022         $tokens = $result;
1023
1024         return $action;
1025     }
1026
1027     /**
48e9c1 1028      * Escape special chars into quoted string value or multi-line string
T 1029      * or list of strings
1030      *
1031      * @param string $str Text or array (list) of strings
1032      *
1033      * @return string Result text
1034      */
1035     static function escape_string($str)
1036     {
1037         if (is_array($str) && count($str) > 1) {
1038             foreach($str as $idx => $val)
1039                 $str[$idx] = self::escape_string($val);
1040
1041             return '[' . implode(',', $str) . ']';
1042         }
1043         else if (is_array($str)) {
1044             $str = array_pop($str);
1045         }
1046
1047         // multi-line string
1048         if (preg_match('/[\r\n\0]/', $str) || strlen($str) > 1024) {
1049             return sprintf("text:\n%s\n.\n", self::escape_multiline_string($str));
1050         }
1051         // quoted-string
1052         else {
1053             return '"' . addcslashes($str, '\\"') . '"';
1054         }
1055     }
1056
1057     /**
1058      * Escape special chars in multi-line string value
1059      *
1060      * @param string $str Text
1061      *
1062      * @return string Text
1063      */
1064     static function escape_multiline_string($str)
1065     {
1066         $str = preg_split('/(\r?\n)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE);
1067
1068         foreach ($str as $idx => $line) {
1069             // dot-stuffing
1070             if (isset($line[0]) && $line[0] == '.') {
1071                 $str[$idx] = '.' . $line;
1072             }
1073         }
1074
1075         return implode($str);
1076     }
1077
1078     /**
1079      * Splits script into string tokens
1080      *
80cb36 1081      * @param string $str       The script
AM 1082      * @param mixed  $num       Number of tokens to return, 0 for all
1083      *                          or True for all tokens until separator is found.
1084      *                          Separator will be returned as last token.
1085      * @param int    &$position Parsing start position
48e9c1 1086      *
T 1087      * @return mixed Tokens array or string if $num=1
1088      */
80cb36 1089     static function tokenize($str, $num = 0, &$position = 0)
48e9c1 1090     {
T 1091         $result = array();
80cb36 1092         $length = strlen($str);
48e9c1 1093
T 1094         // remove spaces from the beginning of the string
80cb36 1095         while ($position < $length && (!$num || $num === true || count($result) < $num)) {
AM 1096             // skip whitespace chars
1097             $position = self::ltrim_position($str, $position);
1098
1099             switch ($str[$position]) {
48e9c1 1100
T 1101             // Quoted string
1102             case '"':
80cb36 1103                 for ($pos = $position + 1; $pos < $length; $pos++) {
48e9c1 1104                     if ($str[$pos] == '"') {
T 1105                         break;
1106                     }
1107                     if ($str[$pos] == "\\") {
1108                         if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
1109                             $pos++;
1110                         }
1111                     }
1112                 }
1113                 if ($str[$pos] != '"') {
1114                     // error
1115                 }
80cb36 1116
48e9c1 1117                 // we need to strip slashes for a quoted string
80cb36 1118                 $result[] = stripslashes(substr($str, $position + 1, $pos - $position - 1));
AM 1119                 $position = $pos + 1;
48e9c1 1120                 break;
T 1121
1122             // Parenthesized list
1123             case '[':
80cb36 1124                 $position++;
AM 1125                 $result[] = self::tokenize($str, 0, $position);
48e9c1 1126                 break;
T 1127             case ']':
80cb36 1128                 $position++;
48e9c1 1129                 return $result;
T 1130                 break;
1131
1132             // list/test separator
1133             case ',':
1134             // command separator
1135             case ';':
1136             // block/tests-list
1137             case '(':
1138             case ')':
1139             case '{':
1140             case '}':
80cb36 1141                 $sep = $str[$position];
AM 1142                 $position++;
48e9c1 1143                 if ($num === true) {
T 1144                     $result[] = $sep;
1145                     break 2;
1146                 }
1147                 break;
1148
1149             // bracket-comment
1150             case '/':
80cb36 1151                 if ($str[$position + 1] == '*') {
AM 1152                     if ($end_pos = strpos($str, '*/', $position + 2)) {
1153                         $position = $end_pos + 2;
48e9c1 1154                     }
T 1155                     else {
1156                         // error
80cb36 1157                         $position = $length;
48e9c1 1158                     }
T 1159                 }
1160                 break;
1161
1162             // hash-comment
1163             case '#':
80cb36 1164                 if ($lf_pos = strpos($str, "\n", $position)) {
AM 1165                     $position = $lf_pos + 1;
48e9c1 1166                     break;
T 1167                 }
1168                 else {
80cb36 1169                     $position = $length;
48e9c1 1170                 }
T 1171
1172             // String atom
1173             default:
1174                 // empty or one character
80cb36 1175                 if ($position == $length) {
48e9c1 1176                     break 2;
T 1177                 }
80cb36 1178                 if ($length - $position < 2) {
AM 1179                     $result[] = substr($str, $position);
1180                     $position = $length;
48e9c1 1181                     break;
T 1182                 }
1183
1184                 // tag/identifier/number
80cb36 1185                 if (preg_match('/[a-zA-Z0-9:_]+/', $str, $m, PREG_OFFSET_CAPTURE, $position)
AM 1186                     && $m[0][1] == $position
1187                 ) {
1188                     $atom      = $m[0][0];
1189                     $position += strlen($atom);
48e9c1 1190
80cb36 1191                     if ($atom != 'text:') {
AM 1192                         $result[] = $atom;
48e9c1 1193                     }
T 1194                     // multiline string
1195                     else {
80cb36 1196                         // skip whitespace chars (except \r\n)
AM 1197                         $position = self::ltrim_position($str, $position, false);
1198
48e9c1 1199                         // possible hash-comment after "text:"
80cb36 1200                         if ($str[$position] === '#') {
AM 1201                             $endl     = strpos($str, "\n", $position);
1202                             $position = $endl ?: $length;
48e9c1 1203                         }
T 1204
80cb36 1205                         // skip \n or \r\n
AM 1206                         if ($str[$position] == "\n") {
1207                             $position++;
1208                         }
1209                         else if ($str[$position] == "\r" && $str[$position] == "\n") {
1210                             $position += 2;
1211                         }
1212
1213                         $text = '';
1214
1215                         // get text until alone dot in a line
1216                         while ($position < $length) {
1217                             $pos = strpos($str, "\n.", $position);
1218                             if ($pos === false) {
1219                                 break;
1220                             }
1221
1222                             $text    .= substr($str, $position, $pos - $position);
1223                             $position = $pos + 2;
1224
1225                             if ($str[$pos] == "\n"
1226                                 || ($str[$pos] == "\r" && $str[$pos + 1] == "\n")
1227                             ) {
1228                                 break;
1229                             }
1230                         }
1231
1232                         // remove dot-stuffing
1233                         $text = str_replace("\n..", "\n.", $text);
1234
48e9c1 1235                         $result[] = $text;
80cb36 1236                         $position++;
48e9c1 1237                     }
T 1238                 }
1239                 // fallback, skip one character as infinite loop prevention
1240                 else {
80cb36 1241                     $position++;
48e9c1 1242                 }
T 1243
1244                 break;
1245             }
1246         }
1247
1248         return $num === 1 ? (isset($result[0]) ? $result[0] : null) : $result;
1249     }
1250
80cb36 1251     /**
AM 1252      * Skip whitespace characters in a string from specified position.
1253      */
1254     static function ltrim_position($content, $position, $br = true)
1255     {
1256         $blanks = array("\t", "\0", "\x0B", " ");
1257
1258         if ($br) {
1259             $blanks[] = "\r";
1260             $blanks[] = "\n";
1261         }
1262
1263         while (isset($content[$position]) && isset($content[$position + 1])
1264             && in_array($content[$position], $blanks, true)
1265         ) {
1266             $position++;
1267         }
1268
1269         return $position;
1270     }
48e9c1 1271 }