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