comparison plugins/managesieve/lib/Roundcube/rcube_sieve_script.php @ 0:1e000243b222

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