]> git.donarmstrong.com Git - roundcube.git/blob - plugins/managesieve/lib/rcube_sieve.php
64bdb20f02384028655dd7c3bc0ff10f913ba751
[roundcube.git] / plugins / managesieve / lib / rcube_sieve.php
1 <?php
2
3 /**
4   Classes for managesieve operations (using PEAR::Net_Sieve)
5
6   Author: Aleksander Machniak <alec@alec.pl>
7
8   $Id: rcube_sieve.php 4555 2011-02-16 10:48:11Z alec $
9
10 */
11
12 //  Sieve Language Basics: http://www.ietf.org/rfc/rfc5228.txt
13
14 define('SIEVE_ERROR_CONNECTION', 1);
15 define('SIEVE_ERROR_LOGIN', 2);
16 define('SIEVE_ERROR_NOT_EXISTS', 3);    // script not exists
17 define('SIEVE_ERROR_INSTALL', 4);       // script installation
18 define('SIEVE_ERROR_ACTIVATE', 5);      // script activation
19 define('SIEVE_ERROR_DELETE', 6);        // script deletion
20 define('SIEVE_ERROR_INTERNAL', 7);      // internal error
21 define('SIEVE_ERROR_DEACTIVATE', 8);    // script activation
22 define('SIEVE_ERROR_OTHER', 255);       // other/unknown error
23
24
25 class rcube_sieve
26 {
27     private $sieve;                 // Net_Sieve object
28     private $error = false;         // error flag
29     private $list = array();        // scripts list
30
31     public $script;                 // rcube_sieve_script object
32     public $current;                // name of currently loaded script
33     private $disabled;              // array of disabled extensions
34
35
36     /**
37      * Object constructor
38      *
39      * @param string  Username (for managesieve login)
40      * @param string  Password (for managesieve login)
41      * @param string  Managesieve server hostname/address
42      * @param string  Managesieve server port number
43      * @param string  Managesieve authentication method 
44      * @param boolean Enable/disable TLS use
45      * @param array   Disabled extensions
46      * @param boolean Enable/disable debugging
47      * @param string  Proxy authentication identifier
48      * @param string  Proxy authentication password
49      */
50     public function __construct($username, $password='', $host='localhost', $port=2000,
51         $auth_type=null, $usetls=true, $disabled=array(), $debug=false,
52         $auth_cid=null, $auth_pw=null)
53     {
54         $this->sieve = new Net_Sieve();
55
56         if ($debug) {
57             $this->sieve->setDebug(true, array($this, 'debug_handler'));
58         }
59
60         if (PEAR::isError($this->sieve->connect($host, $port, null, $usetls))) {
61             return $this->_set_error(SIEVE_ERROR_CONNECTION);
62         }
63
64         if (!empty($auth_cid)) {
65             $authz    = $username;
66             $username = $auth_cid;
67             $password = $auth_pw;
68         }
69
70         if (PEAR::isError($this->sieve->login($username, $password,
71             $auth_type ? strtoupper($auth_type) : null, $authz))
72         ) {
73             return $this->_set_error(SIEVE_ERROR_LOGIN);
74         }
75
76         $this->disabled = $disabled;
77     }
78
79     public function __destruct() {
80         $this->sieve->disconnect();
81     }
82
83     /**
84      * Getter for error code
85      */
86     public function error()
87     {
88         return $this->error ? $this->error : false;
89     }
90
91     /**
92      * Saves current script into server
93      */
94     public function save($name = null)
95     {
96         if (!$this->sieve)
97             return $this->_set_error(SIEVE_ERROR_INTERNAL);
98
99         if (!$this->script)
100             return $this->_set_error(SIEVE_ERROR_INTERNAL);
101
102         if (!$name)
103             $name = $this->current;
104
105         $script = $this->script->as_text();
106
107         if (!$script)
108             $script = '/* empty script */';
109
110         if (PEAR::isError($this->sieve->installScript($name, $script)))
111             return $this->_set_error(SIEVE_ERROR_INSTALL);
112
113         return true;
114     }
115
116     /**
117      * Saves text script into server
118      */
119     public function save_script($name, $content = null)
120     {
121         if (!$this->sieve)
122             return $this->_set_error(SIEVE_ERROR_INTERNAL);
123
124         if (!$content)
125             $content = '/* empty script */';
126
127         if (PEAR::isError($this->sieve->installScript($name, $content)))
128             return $this->_set_error(SIEVE_ERROR_INSTALL);
129
130         return true;
131     }
132
133     /**
134      * Activates specified script
135      */
136     public function activate($name = null)
137     {
138         if (!$this->sieve)
139             return $this->_set_error(SIEVE_ERROR_INTERNAL);
140
141         if (!$name)
142             $name = $this->current;
143
144         if (PEAR::isError($this->sieve->setActive($name)))
145             return $this->_set_error(SIEVE_ERROR_ACTIVATE);
146
147         return true;
148     }
149
150     /**
151      * De-activates specified script
152      */
153     public function deactivate()
154     {
155         if (!$this->sieve)
156             return $this->_set_error(SIEVE_ERROR_INTERNAL);
157
158         if (PEAR::isError($this->sieve->setActive('')))
159             return $this->_set_error(SIEVE_ERROR_DEACTIVATE);
160
161         return true;
162     }
163
164     /**
165      * Removes specified script
166      */
167     public function remove($name = null)
168     {
169         if (!$this->sieve)
170             return $this->_set_error(SIEVE_ERROR_INTERNAL);
171
172         if (!$name)
173             $name = $this->current;
174
175         // script must be deactivated first
176         if ($name == $this->sieve->getActive())
177             if (PEAR::isError($this->sieve->setActive('')))
178                 return $this->_set_error(SIEVE_ERROR_DELETE);
179
180         if (PEAR::isError($this->sieve->removeScript($name)))
181             return $this->_set_error(SIEVE_ERROR_DELETE);
182
183         if ($name == $this->current)
184             $this->current = null;
185
186         return true;
187     }
188
189     /**
190      * Gets list of supported by server Sieve extensions
191      */
192     public function get_extensions()
193     {
194         if (!$this->sieve)
195             return $this->_set_error(SIEVE_ERROR_INTERNAL);
196
197         $ext = $this->sieve->getExtensions();
198         // we're working on lower-cased names
199         $ext = array_map('strtolower', (array) $ext);
200
201         if ($this->script) {
202             $supported = $this->script->get_extensions();
203             foreach ($ext as $idx => $ext_name)
204                 if (!in_array($ext_name, $supported))
205                     unset($ext[$idx]);
206         }
207
208         return array_values($ext);
209     }
210
211     /**
212      * Gets list of scripts from server
213      */
214     public function get_scripts()
215     {
216         if (!$this->list) {
217
218             if (!$this->sieve)
219                 return $this->_set_error(SIEVE_ERROR_INTERNAL);
220
221             $this->list = $this->sieve->listScripts();
222
223             if (PEAR::isError($this->list))
224                 return $this->_set_error(SIEVE_ERROR_OTHER);
225         }
226
227         return $this->list;
228     }
229
230     /**
231      * Returns active script name
232      */
233     public function get_active()
234     {
235         if (!$this->sieve)
236             return $this->_set_error(SIEVE_ERROR_INTERNAL);
237
238         return $this->sieve->getActive();
239     }
240
241     /**
242      * Loads script by name
243      */
244     public function load($name)
245     {
246         if (!$this->sieve)
247             return $this->_set_error(SIEVE_ERROR_INTERNAL);
248
249         if ($this->current == $name)
250             return true;
251
252         $script = $this->sieve->getScript($name);
253
254         if (PEAR::isError($script))
255             return $this->_set_error(SIEVE_ERROR_OTHER);
256
257         // try to parse from Roundcube format
258         $this->script = $this->_parse($script);
259
260         $this->current = $name;
261
262         return true;
263     }
264
265     /**
266      * Loads script from text content
267      */
268     public function load_script($script)
269     {
270         if (!$this->sieve)
271             return $this->_set_error(SIEVE_ERROR_INTERNAL);
272
273         // try to parse from Roundcube format
274         $this->script = $this->_parse($script);
275     }
276
277     /**
278      * Creates rcube_sieve_script object from text script
279      */
280     private function _parse($txt)
281     {
282         // try to parse from Roundcube format
283         $script = new rcube_sieve_script($txt, $this->disabled);
284
285         // ... else try to import from different formats
286         if (empty($script->content)) {
287             $script = $this->_import_rules($txt);
288             $script = new rcube_sieve_script($script, $this->disabled);
289         }
290
291         // replace all elsif with if+stop, we support only ifs
292         foreach ($script->content as $idx => $rule) {
293             if (!isset($script->content[$idx+1])
294                 || preg_match('/^else|elsif$/', $script->content[$idx+1]['type'])) {
295                 // 'stop' not found?
296                 if (!preg_match('/^(stop|vacation)$/', $rule['actions'][count($rule['actions'])-1]['type'])) {
297                     $script->content[$idx]['actions'][] = array(
298                         'type' => 'stop'
299                     );
300                 }
301             }
302         }
303
304         return $script;
305     }
306
307     /**
308      * Gets specified script as text
309      */
310     public function get_script($name)
311     {
312         if (!$this->sieve)
313             return $this->_set_error(SIEVE_ERROR_INTERNAL);
314
315         $content = $this->sieve->getScript($name);
316
317         if (PEAR::isError($content))
318             return $this->_set_error(SIEVE_ERROR_OTHER);
319
320         return $content;
321     }
322
323     /**
324      * Creates empty script or copy of other script
325      */
326     public function copy($name, $copy)
327     {
328         if (!$this->sieve)
329             return $this->_set_error(SIEVE_ERROR_INTERNAL);
330
331         if ($copy) {
332             $content = $this->sieve->getScript($copy);
333
334             if (PEAR::isError($content))
335                 return $this->_set_error(SIEVE_ERROR_OTHER);
336         }
337
338         return $this->save_script($name, $content);
339     }
340
341     private function _import_rules($script)
342     {
343         $i = 0;
344         $name = array();
345
346         // Squirrelmail (Avelsieve)
347         if ($tokens = preg_split('/(#START_SIEVE_RULE.*END_SIEVE_RULE)\r?\n/', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
348             foreach($tokens as $token) {
349                 if (preg_match('/^#START_SIEVE_RULE.*/', $token, $matches)) {
350                     $name[$i] = "unnamed rule ".($i+1);
351                     $content .= "# rule:[".$name[$i]."]\n";
352                 }
353                 elseif (isset($name[$i])) {
354                     // This preg_replace is added because I've found some Avelsieve scripts
355                     // with rules containing "if" here. I'm not sure it was working
356                     // before without this or not.
357                     $token = preg_replace('/^if\s+/', '', trim($token));
358                     $content .= "if $token\n";
359                     $i++;
360                 }
361             }
362         }
363         // Horde (INGO)
364         else if ($tokens = preg_split('/(# .+)\r?\n/i', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
365             foreach($tokens as $token) {
366                 if (preg_match('/^# (.+)/i', $token, $matches)) {
367                     $name[$i] = $matches[1];
368                     $content .= "# rule:[" . $name[$i] . "]\n";
369                 }
370                 elseif (isset($name[$i])) {
371                     $token = str_replace(":comparator \"i;ascii-casemap\" ", "", $token);
372                     $content .= $token . "\n";
373                     $i++;
374                 }
375             }
376         }
377
378         return $content;
379     }
380
381     private function _set_error($error)
382     {
383         $this->error = $error;
384         return false;
385     }
386
387     /**
388      * This is our own debug handler for connection
389      */
390     public function debug_handler(&$sieve, $message)
391     {
392         write_log('sieve', preg_replace('/\r\n$/', '', $message));
393     }
394 }
395
396
397 class rcube_sieve_script
398 {
399     public $content = array();      // script rules array
400
401     private $supported = array(     // extensions supported by class
402         'fileinto',
403         'reject',
404         'ereject',
405         'copy',                     // RFC3894
406         'vacation',                 // RFC5230
407         'relational',               // RFC3431
408     // TODO: (most wanted first) body, imapflags, notify, regex
409     );
410
411     /**
412      * Object constructor
413      *
414      * @param  string  Script's text content
415      * @param  array   Disabled extensions
416      */
417     public function __construct($script, $disabled=null)
418     {
419         if (!empty($disabled)) {
420             // we're working on lower-cased names
421             $disabled = array_map('strtolower', (array) $disabled);
422             foreach ($disabled as $ext) {
423                 if (($idx = array_search($ext, $this->supported)) !== false) {
424                     unset($this->supported[$idx]);
425                 }
426             }
427         }
428
429         $this->content = $this->_parse_text($script);
430     }
431
432     /**
433      * Adds script contents as text to the script array (at the end)
434      *
435      * @param    string    Text script contents
436      */
437     public function add_text($script)
438     {
439         $content = $this->_parse_text($script);
440         $result = false;
441
442         // check existsing script rules names
443         foreach ($this->content as $idx => $elem) {
444             $names[$elem['name']] = $idx;
445         }
446
447         foreach ($content as $elem) {
448             if (!isset($names[$elem['name']])) {
449                 array_push($this->content, $elem);
450                 $result = true;
451             }
452         }
453
454         return $result;
455     }
456
457     /**
458      * Adds rule to the script (at the end)
459      *
460      * @param string Rule name
461      * @param array  Rule content (as array)
462      */
463     public function add_rule($content)
464     {
465         // TODO: check this->supported
466         array_push($this->content, $content);
467         return sizeof($this->content)-1;
468     }
469
470     public function delete_rule($index)
471     {
472         if(isset($this->content[$index])) {
473             unset($this->content[$index]);
474             return true;
475         }
476         return false;
477     }
478
479     public function size()
480     {
481         return sizeof($this->content);
482     }
483
484     public function update_rule($index, $content)
485     {
486         // TODO: check this->supported
487         if ($this->content[$index]) {
488             $this->content[$index] = $content;
489             return $index;
490         }
491         return false;
492     }
493
494     /**
495      * Returns script as text
496      */
497     public function as_text()
498     {
499         $script = '';
500         $exts = array();
501         $idx = 0;
502
503         // rules
504         foreach ($this->content as $rule) {
505             $extension = '';
506             $tests = array();
507             $i = 0;
508
509             // header
510             $script .= '# rule:[' . $rule['name'] . "]\n";
511
512             // constraints expressions
513             foreach ($rule['tests'] as $test) {
514                 $tests[$i] = '';
515                 switch ($test['test']) {
516                 case 'size':
517                     $tests[$i] .= ($test['not'] ? 'not ' : '');
518                     $tests[$i] .= 'size :' . ($test['type']=='under' ? 'under ' : 'over ') . $test['arg'];
519                     break;
520                 case 'true':
521                     $tests[$i] .= ($test['not'] ? 'false' : 'true');
522                     break;
523                 case 'exists':
524                     $tests[$i] .= ($test['not'] ? 'not ' : '');
525                     $tests[$i] .= 'exists ' . self::escape_string($test['arg']);
526                     break;
527                 case 'header':
528                     $tests[$i] .= ($test['not'] ? 'not ' : '');
529
530                     // relational operator + comparator
531                                         if (preg_match('/^(value|count)-([gteqnl]{2})/', $test['type'], $m)) {
532                                                 array_push($exts, 'relational');
533                                                 array_push($exts, 'comparator-i;ascii-numeric');
534                         $tests[$i] .= 'header :' . $m[1] . ' "' . $m[2] . '" :comparator "i;ascii-numeric"';
535                     }
536                     else
537                         $tests[$i] .= 'header :' . $test['type'];
538
539                     $tests[$i] .= ' ' . self::escape_string($test['arg1']);
540                     $tests[$i] .= ' ' . self::escape_string($test['arg2']);
541                     break;
542                 }
543                 $i++;
544             }
545
546             // disabled rule: if false #....
547             $script .= 'if ' . ($rule['disabled'] ? 'false # ' : '');
548
549             if (empty($tests)) {
550                 $tests_str = 'true';
551             }
552             else if (count($tests) > 1) {
553                 $tests_str = implode(', ', $tests);
554             }
555             else {
556                 $tests_str = $tests[0];
557             }
558
559             if ($rule['join'] || count($tests) > 1) {
560                 $script .= sprintf('%s (%s)', $rule['join'] ? 'allof' : 'anyof', $tests_str);
561             }
562             else {
563                 $script .= $tests_str;
564             }
565             $script .= "\n{\n";
566
567             // action(s)
568             foreach ($rule['actions'] as $action) {
569                 switch ($action['type']) {
570                 case 'fileinto':
571                     array_push($exts, 'fileinto');
572                     $script .= "\tfileinto ";
573                     if ($action['copy']) {
574                         $script .= ':copy ';
575                         array_push($exts, 'copy');
576                     }
577                     $script .= self::escape_string($action['target']) . ";\n";
578                     break;
579                 case 'redirect':
580                     $script .= "\tredirect ";
581                     if ($action['copy']) {
582                         $script .= ':copy ';
583                         array_push($exts, 'copy');
584                     }
585                     $script .= self::escape_string($action['target']) . ";\n";
586                     break;
587                 case 'reject':
588                 case 'ereject':
589                     array_push($exts, $action['type']);
590                     $script .= "\t".$action['type']." "
591                         . self::escape_string($action['target']) . ";\n";
592                     break;
593                 case 'keep':
594                 case 'discard':
595                 case 'stop':
596                     $script .= "\t" . $action['type'] .";\n";
597                     break;
598                 case 'vacation':
599                     array_push($exts, 'vacation');
600                     $script .= "\tvacation";
601                     if (!empty($action['days']))
602                         $script .= " :days " . $action['days'];
603                     if (!empty($action['addresses']))
604                         $script .= " :addresses " . self::escape_string($action['addresses']);
605                     if (!empty($action['subject']))
606                         $script .= " :subject " . self::escape_string($action['subject']);
607                     if (!empty($action['handle']))
608                         $script .= " :handle " . self::escape_string($action['handle']);
609                     if (!empty($action['from']))
610                         $script .= " :from " . self::escape_string($action['from']);
611                     if (!empty($action['mime']))
612                         $script .= " :mime";
613                     $script .= " " . self::escape_string($action['reason']) . ";\n";
614                     break;
615                 }
616             }
617
618             $script .= "}\n";
619             $idx++;
620         }
621
622         // requires
623         if (!empty($exts))
624             $script = 'require ["' . implode('","', array_unique($exts)) . "\"];\n" . $script;
625
626         return $script;
627     }
628
629     /**
630      * Returns script object
631      *
632      */
633     public function as_array()
634     {
635         return $this->content;
636     }
637
638     /**
639      * Returns array of supported extensions
640      *
641      */
642     public function get_extensions()
643     {
644         return array_values($this->supported);
645     }
646
647     /**
648      * Converts text script to rules array
649      *
650      * @param string Text script
651      */
652     private function _parse_text($script)
653     {
654         $i = 0;
655         $content = array();
656
657         // tokenize rules
658         if ($tokens = preg_split('/(# rule:\[.*\])\r?\n/', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
659             foreach($tokens as $token) {
660                 if (preg_match('/^# rule:\[(.*)\]/', $token, $matches)) {
661                     $content[$i]['name'] = $matches[1];
662                 }
663                 else if (isset($content[$i]['name']) && sizeof($content[$i]) == 1) {
664                     if ($rule = $this->_tokenize_rule($token)) {
665                         $content[$i] = array_merge($content[$i], $rule);
666                         $i++;
667                     }
668                     else // unknown rule format
669                         unset($content[$i]);
670                 }
671             }
672         }
673
674         return $content;
675     }
676
677     /**
678      * Convert text script fragment to rule object
679      *
680      * @param string Text rule
681      */
682     private function _tokenize_rule($content)
683     {
684         $cond = strtolower(self::tokenize($content, 1));
685
686         if ($cond != 'if' && $cond != 'elsif' && $cond != 'else') {
687             return null;
688         }
689
690         $disabled = false;
691         $join     = false;
692
693         // disabled rule (false + comment): if false # .....
694         if (preg_match('/^\s*false\s+#/i', $content)) {
695             $content = preg_replace('/^\s*false\s+#\s*/i', '', $content);
696             $disabled = true;
697         }
698
699         while (strlen($content)) {
700             $tokens = self::tokenize($content, true);
701             $separator = array_pop($tokens);
702
703             if (!empty($tokens)) {
704                 $token = array_shift($tokens);
705             }
706             else {
707                 $token = $separator;
708             }
709
710             $token = strtolower($token);
711
712             if ($token == 'not') {
713                 $not = true;
714                 $token = strtolower(array_shift($tokens));
715             }
716             else {
717                 $not = false;
718             }
719
720             switch ($token) {
721             case 'allof':
722                 $join = true;
723                 break;
724             case 'anyof':
725                 break;
726
727             case 'size':
728                 $size = array('test' => 'size', 'not'  => $not);
729                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
730                     if (!is_array($tokens[$i])
731                         && preg_match('/^:(under|over)$/i', $tokens[$i])
732                     ) {
733                         $size['type'] = strtolower(substr($tokens[$i], 1));
734                     }
735                     else {
736                         $size['arg'] = $tokens[$i];
737                     }
738                 }
739
740                 $tests[] = $size;
741                 break;
742
743             case 'header':
744                 $header = array('test' => 'header', 'not' => $not, 'arg1' => '', 'arg2' => '');
745                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
746                     if (!is_array($tokens[$i]) && preg_match('/^:comparator$/i', $tokens[$i])) {
747                         $i++;
748                     }
749                     else if (!is_array($tokens[$i]) && preg_match('/^:(count|value)$/i', $tokens[$i])) {
750                         $header['type'] = strtolower(substr($tokens[$i], 1)) . '-' . $tokens[++$i];
751                     }
752                     else if (!is_array($tokens[$i]) && preg_match('/^:(is|contains|matches)$/i', $tokens[$i])) {
753                         $header['type'] = strtolower(substr($tokens[$i], 1));
754                     }
755                     else {
756                         $header['arg1'] = $header['arg2'];
757                         $header['arg2'] = $tokens[$i];
758                     }
759                 }
760
761                 $tests[] = $header;
762                 break;
763
764             case 'exists':
765                 $tests[] = array('test' => 'exists', 'not'  => $not,
766                     'arg'  => array_pop($tokens));
767                 break;
768
769             case 'true':
770                 $tests[] = array('test' => 'true', 'not'  => $not);
771                 break;
772
773             case 'false':
774                 $tests[] = array('test' => 'true', 'not'  => !$not);
775                 break;
776             }
777
778             // goto actions...
779             if ($separator == '{') {
780                 break;
781             }
782         }
783
784         // ...and actions block
785         if ($tests) {
786             $actions = $this->_parse_actions($content);
787         }
788
789         if ($tests && $actions) {
790             $result = array(
791                 'type'     => $cond,
792                 'tests'    => $tests,
793                 'actions'  => $actions,
794                 'join'     => $join,
795                 'disabled' => $disabled,
796             );
797         }
798
799         return $result;
800     }
801
802     /**
803      * Parse body of actions section
804      *
805      * @param string Text body
806      * @return array Array of parsed action type/target pairs
807      */
808     private function _parse_actions($content)
809     {
810         $result = null;
811
812         while (strlen($content)) {
813             $tokens = self::tokenize($content, true);
814             $separator = array_pop($tokens);
815
816             if (!empty($tokens)) {
817                 $token = array_shift($tokens);
818             }
819             else {
820                 $token = $separator;
821             }
822
823             switch ($token) {
824             case 'discard':
825             case 'keep':
826             case 'stop':
827                 $result[] = array('type' => $token);
828                 break;
829
830             case 'fileinto':
831             case 'redirect':
832                 $copy   = false;
833                 $target = '';
834
835                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
836                     if (strtolower($tokens[$i]) == ':copy') {
837                         $copy = true;
838                     }
839                     else {
840                         $target = $tokens[$i];
841                     }
842                 }
843
844                 $result[] = array('type' => $token, 'copy' => $copy,
845                     'target' => $target);
846                 break;
847
848             case 'reject':
849             case 'ereject':
850                 $result[] = array('type' => $token, 'target' => array_pop($tokens));
851                 break;
852
853             case 'vacation':
854                 $vacation = array('type' => 'vacation', 'reason' => array_pop($tokens));
855
856                 for ($i=0, $len=count($tokens); $i<$len; $i++) {
857                     $tok = strtolower($tokens[$i]);
858                     if ($tok == ':days') {
859                         $vacation['days'] = $tokens[++$i];
860                     }
861                     else if ($tok == ':subject') {
862                         $vacation['subject'] = $tokens[++$i];
863                     }
864                     else if ($tok == ':addresses') {
865                         $vacation['addresses'] = $tokens[++$i];
866                     }
867                     else if ($tok == ':handle') {
868                         $vacation['handle'] = $tokens[++$i];
869                     }
870                     else if ($tok == ':from') {
871                         $vacation['from'] = $tokens[++$i];
872                     }
873                     else if ($tok == ':mime') {
874                         $vacation['mime'] = true;
875                     }
876                 }
877
878                 $result[] = $vacation;
879                 break;
880             }
881         }
882
883         return $result;
884     }
885
886     /**
887      * Escape special chars into quoted string value or multi-line string
888      * or list of strings
889      *
890      * @param string $str Text or array (list) of strings
891      *
892      * @return string Result text
893      */
894     static function escape_string($str)
895     {
896         if (is_array($str) && count($str) > 1) {
897             foreach($str as $idx => $val)
898                 $str[$idx] = self::escape_string($val);
899
900             return '[' . implode(',', $str) . ']';
901         }
902         else if (is_array($str)) {
903             $str = array_pop($str);
904         }
905
906         // multi-line string
907         if (preg_match('/[\r\n\0]/', $str) || strlen($str) > 1024) {
908             return sprintf("text:\n%s\n.\n", self::escape_multiline_string($str));
909         }
910         // quoted-string
911         else {
912             $replace = array('\\' => '\\\\', '"' => '\\"');
913             $str = str_replace(array_keys($replace), array_values($replace), $str);
914             return '"' . $str . '"';
915         }
916     }
917
918     /**
919      * Escape special chars in multi-line string value
920      *
921      * @param string $str Text
922      *
923      * @return string Text
924      */
925     static function escape_multiline_string($str)
926     {
927         $str = preg_split('/(\r?\n)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE);
928
929         foreach ($str as $idx => $line) {
930             // dot-stuffing
931             if (isset($line[0]) && $line[0] == '.') {
932                 $str[$idx] = '.' . $line;
933             }
934         }
935
936         return implode($str);
937     }
938
939     /**
940      * Splits script into string tokens
941      *
942      * @param string &$str    The script
943      * @param mixed  $num     Number of tokens to return, 0 for all
944      *                        or True for all tokens until separator is found.
945      *                        Separator will be returned as last token.
946      * @param int    $in_list Enable to called recursively inside a list
947      *
948      * @return mixed Tokens array or string if $num=1
949      */
950     static function tokenize(&$str, $num=0, $in_list=false)
951     {
952         $result = array();
953
954         // remove spaces from the beginning of the string
955         while (($str = ltrim($str)) !== ''
956             && (!$num || $num === true || count($result) < $num)
957         ) {
958             switch ($str[0]) {
959
960             // Quoted string
961             case '"':
962                 $len = strlen($str);
963
964                 for ($pos=1; $pos<$len; $pos++) {
965                     if ($str[$pos] == '"') {
966                         break;
967                     }
968                     if ($str[$pos] == "\\") {
969                         if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
970                             $pos++;
971                         }
972                     }
973                 }
974                 if ($str[$pos] != '"') {
975                     // error
976                 }
977                 // we need to strip slashes for a quoted string
978                 $result[] = stripslashes(substr($str, 1, $pos - 1));
979                 $str      = substr($str, $pos + 1);
980                 break;
981
982             // Parenthesized list
983             case '[':
984                 $str = substr($str, 1);
985                 $result[] = self::tokenize($str, 0, true);
986                 break;
987             case ']':
988                 $str = substr($str, 1);
989                 return $result;
990                 break;
991
992             // list/test separator
993             case ',':
994             // command separator
995             case ';':
996             // block/tests-list
997             case '(':
998             case ')':
999             case '{':
1000             case '}':
1001                 $sep = $str[0];
1002                 $str = substr($str, 1);
1003                 if ($num === true) {
1004                     $result[] = $sep;
1005                     break 2; 
1006                 }
1007                 break;
1008
1009             // bracket-comment
1010             case '/':
1011                 if ($str[1] == '*') {
1012                     if ($end_pos = strpos($str, '*/')) {
1013                         $str = substr($str, $end_pos + 2);
1014                     }
1015                     else {
1016                         // error
1017                         $str = '';
1018                     }
1019                 }
1020                 break;
1021
1022             // hash-comment
1023             case '#':
1024                 if ($lf_pos = strpos($str, "\n")) {
1025                     $str = substr($str, $lf_pos);
1026                     break;
1027                 }
1028                 else {
1029                     $str = '';
1030                 }
1031
1032             // String atom
1033             default:
1034                 // empty or one character
1035                 if ($str === '') {
1036                     break 2;
1037                 }
1038                 if (strlen($str) < 2) {
1039                     $result[] = $str;
1040                     $str = '';
1041                     break;
1042                 }
1043
1044                 // tag/identifier/number
1045                 if (preg_match('/^([a-z0-9:_]+)/i', $str, $m)) {
1046                     $str = substr($str, strlen($m[1]));
1047
1048                     if ($m[1] != 'text:') {
1049                         $result[] = $m[1];
1050                     }
1051                     // multiline string
1052                     else {
1053                         // possible hash-comment after "text:"
1054                         if (preg_match('/^( |\t)*(#[^\n]+)?\n/', $str, $m)) {
1055                             $str = substr($str, strlen($m[0]));
1056                         }
1057                         // get text until alone dot in a line
1058                         if (preg_match('/^(.*)\r?\n\.\r?\n/sU', $str, $m)) {
1059                             $text = $m[1];
1060                             // remove dot-stuffing
1061                             $text = str_replace("\n..", "\n.", $text);
1062                             $str = substr($str, strlen($m[0]));
1063                         }
1064                         else {
1065                             $text = '';
1066                         }
1067
1068                         $result[] = $text;
1069                     }
1070                 }
1071
1072                 break;
1073             }
1074         }
1075
1076         return $num === 1 ? (isset($result[0]) ? $result[0] : null) : $result;
1077     }
1078
1079 }