]> git.donarmstrong.com Git - roundcube.git/blob - plugins/managesieve/lib/rcube_sieve.php
Imported Debian patch 0.5.1+dfsg-7
[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 4241 2010-11-20 17:59:50Z 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             foreach ($disabled as $ext)
421                 if (($idx = array_search($ext, $this->supported)) !== false)
422                     unset($this->supported[$idx]);
423
424         $this->content = $this->_parse_text($script);
425     }
426
427     /**
428      * Adds script contents as text to the script array (at the end)
429      *
430      * @param    string    Text script contents
431      */
432     public function add_text($script)
433     {
434         $content = $this->_parse_text($script);
435         $result = false;
436
437         // check existsing script rules names
438         foreach ($this->content as $idx => $elem) {
439             $names[$elem['name']] = $idx;
440         }
441
442         foreach ($content as $elem) {
443             if (!isset($names[$elem['name']])) {
444                 array_push($this->content, $elem);
445                 $result = true;
446             }
447         }
448
449         return $result;
450     }
451
452     /**
453      * Adds rule to the script (at the end)
454      *
455      * @param string Rule name
456      * @param array  Rule content (as array)
457      */
458     public function add_rule($content)
459     {
460         // TODO: check this->supported
461         array_push($this->content, $content);
462         return sizeof($this->content)-1;
463     }
464
465     public function delete_rule($index)
466     {
467         if(isset($this->content[$index])) {
468             unset($this->content[$index]);
469             return true;
470         }
471         return false;
472     }
473
474     public function size()
475     {
476         return sizeof($this->content);
477     }
478
479     public function update_rule($index, $content)
480     {
481         // TODO: check this->supported
482         if ($this->content[$index]) {
483             $this->content[$index] = $content;
484             return $index;
485         }
486         return false;
487     }
488
489     /**
490      * Returns script as text
491      */
492     public function as_text()
493     {
494         $script = '';
495         $exts = array();
496         $idx = 0;
497
498         // rules
499         foreach ($this->content as $rule) {
500             $extension = '';
501             $tests = array();
502             $i = 0;
503
504             // header
505             $script .= '# rule:[' . $rule['name'] . "]\n";
506
507             // constraints expressions
508             foreach ($rule['tests'] as $test) {
509                 $tests[$i] = '';
510                 switch ($test['test']) {
511                 case 'size':
512                     $tests[$i] .= ($test['not'] ? 'not ' : '');
513                     $tests[$i] .= 'size :' . ($test['type']=='under' ? 'under ' : 'over ') . $test['arg'];
514                     break;
515                 case 'true':
516                     $tests[$i] .= ($test['not'] ? 'not true' : 'true');
517                     break;
518                 case 'exists':
519                     $tests[$i] .= ($test['not'] ? 'not ' : '');
520                     if (is_array($test['arg']))
521                         $tests[$i] .= 'exists ["' . implode('", "', $this->_escape_string($test['arg'])) . '"]';
522                     else
523                         $tests[$i] .= 'exists "' . $this->_escape_string($test['arg']) . '"';
524                     break;
525                 case 'header':
526                     $tests[$i] .= ($test['not'] ? 'not ' : '');
527
528                     // relational operator + comparator
529                                         if (preg_match('/^(value|count)-([gteqnl]{2})/', $test['type'], $m)) {
530                                                 array_push($exts, 'relational');
531                                                 array_push($exts, 'comparator-i;ascii-numeric');
532                         $tests[$i] .= 'header :' . $m[1] . ' "' . $m[2] . '" :comparator "i;ascii-numeric"';
533                     }
534                     else
535                         $tests[$i] .= 'header :' . $test['type'];
536                     
537                     if (is_array($test['arg1']))
538                         $tests[$i] .= ' ["' . implode('", "', $this->_escape_string($test['arg1'])) . '"]';
539                     else
540                         $tests[$i] .= ' "' . $this->_escape_string($test['arg1']) . '"';
541
542                     if (is_array($test['arg2']))
543                         $tests[$i] .= ' ["' . implode('", "', $this->_escape_string($test['arg2'])) . '"]';
544                     else
545                         $tests[$i] .= ' "' . $this->_escape_string($test['arg2']) . '"';
546
547                     break;
548                 }
549                 $i++;
550             }
551
552 //          $script .= ($idx>0 ? 'els' : '').($rule['join'] ? 'if allof (' : 'if anyof (');
553             // disabled rule: if false #....
554             $script .= 'if' . ($rule['disabled'] ? ' false #' : '');
555             $script .= $rule['join'] ? ' allof (' : ' anyof (';
556             if (sizeof($tests) > 1)
557                 $script .= implode(", ", $tests);
558             else if (sizeof($tests))
559                 $script .= $tests[0];
560             else
561                 $script .= 'true';
562             $script .= ")\n{\n";
563
564             // action(s)
565             foreach ($rule['actions'] as $action) {
566                 switch ($action['type']) {
567                 case 'fileinto':
568                     array_push($exts, 'fileinto');
569                     $script .= "\tfileinto ";
570                     if ($action['copy']) {
571                         $script .= ':copy ';
572                         array_push($exts, 'copy');
573                     }
574                     $script .= "\"" . $this->_escape_string($action['target']) . "\";\n";
575                     break;
576                 case 'redirect':
577                     $script .= "\tredirect ";
578                     if ($action['copy']) {
579                         $script .= ':copy ';
580                         array_push($exts, 'copy');
581                     }
582                     $script .= "\"" . $this->_escape_string($action['target']) . "\";\n";
583                     break;
584                 case 'reject':
585                 case 'ereject':
586                     array_push($exts, $action['type']);
587                     if (strpos($action['target'], "\n")!==false)
588                         $script .= "\t".$action['type']." text:\n" . $action['target'] . "\n.\n;\n";
589                     else
590                         $script .= "\t".$action['type']." \"" . $this->_escape_string($action['target']) . "\";\n";
591                     break;
592                 case 'keep':
593                 case 'discard':
594                 case 'stop':
595                     $script .= "\t" . $action['type'] .";\n";
596                     break;
597                 case 'vacation':
598                     array_push($exts, 'vacation');
599                     $script .= "\tvacation";
600                     if ($action['days'])
601                         $script .= " :days " . $action['days'];
602                     if ($action['addresses'])
603                         $script .= " :addresses " . $this->_print_list($action['addresses']);
604                     if ($action['subject'])
605                         $script .= " :subject \"" . $this->_escape_string($action['subject']) . "\"";
606                     if ($action['handle'])
607                         $script .= " :handle \"" . $this->_escape_string($action['handle']) . "\"";
608                     if ($action['from'])
609                         $script .= " :from \"" . $this->_escape_string($action['from']) . "\"";
610                     if ($action['mime'])
611                         $script .= " :mime";
612                     if (strpos($action['reason'], "\n")!==false)
613                         $script .= " text:\n" . $action['reason'] . "\n.\n;\n";
614                     else
615                         $script .= " \"" . $this->_escape_string($action['reason']) . "\";\n";
616                     break;
617                 }
618             }
619
620             $script .= "}\n";
621             $idx++;
622         }
623
624         // requires
625         if (!empty($exts))
626             $script = 'require ["' . implode('","', array_unique($exts)) . "\"];\n" . $script;
627
628         return $script;
629     }
630
631     /**
632      * Returns script object
633      *
634      */
635     public function as_array()
636     {
637         return $this->content;
638     }
639
640     /**
641      * Returns array of supported extensions
642      *
643      */
644     public function get_extensions()
645     {
646         return array_values($this->supported);
647     }
648
649     /**
650      * Converts text script to rules array
651      *
652      * @param string Text script
653      */
654     private function _parse_text($script)
655     {
656         $i = 0;
657         $content = array();
658
659         // remove C comments
660         $script = preg_replace('|/\*.*?\*/|sm', '', $script);
661
662         // tokenize rules
663         if ($tokens = preg_split('/(# rule:\[.*\])\r?\n/', $script, -1, PREG_SPLIT_DELIM_CAPTURE)) {
664             foreach($tokens as $token) {
665                 if (preg_match('/^# rule:\[(.*)\]/', $token, $matches)) {
666                     $content[$i]['name'] = $matches[1];
667                 }
668                 else if (isset($content[$i]['name']) && sizeof($content[$i]) == 1) {
669                     if ($rule = $this->_tokenize_rule($token)) {
670                         $content[$i] = array_merge($content[$i], $rule);
671                         $i++;
672                     }
673                     else // unknown rule format
674                         unset($content[$i]);
675                 }
676             }
677         }
678
679         return $content;
680     }
681
682     /**
683      * Convert text script fragment to rule object
684      *
685      * @param string Text rule
686      */
687     private function _tokenize_rule($content)
688     {
689         $result = NULL;
690
691         if (preg_match('/^(if|elsif|else)\s+((true|false|not\s+true|allof|anyof|exists|header|not|size)(.*))\s+\{(.*)\}$/sm',
692             trim($content), $matches)) {
693
694             $tests = trim($matches[2]);
695
696             // disabled rule (false + comment): if false #.....
697             if ($matches[3] == 'false') {
698                 $tests = preg_replace('/^false\s+#\s+/', '', $tests);
699                 $disabled = true;
700             }
701             else
702                 $disabled = false;
703
704             list($tests, $join) = $this->_parse_tests($tests);
705             $actions = $this->_parse_actions(trim($matches[5]));
706
707             if ($tests && $actions)
708                 $result = array(
709                     'type'     => $matches[1],
710                     'tests'    => $tests,
711                     'actions'  => $actions,
712                     'join'     => $join,
713                     'disabled' => $disabled,
714             );
715         }
716
717         return $result;
718     }
719
720     /**
721      * Parse body of actions section
722      *
723      * @param string Text body
724      * @return array Array of parsed action type/target pairs
725      */
726     private function _parse_actions($content)
727     {
728         $result = NULL;
729
730         // supported actions
731         $patterns[] = '^\s*discard;';
732         $patterns[] = '^\s*keep;';
733         $patterns[] = '^\s*stop;';
734         $patterns[] = '^\s*redirect\s+(.*?[^\\\]);';
735         if (in_array('fileinto', $this->supported))
736             $patterns[] = '^\s*fileinto\s+(.*?[^\\\]);';
737         if (in_array('reject', $this->supported)) {
738             $patterns[] = '^\s*reject\s+text:(.*)\n\.\n;';
739             $patterns[] = '^\s*reject\s+(.*?[^\\\]);';
740             $patterns[] = '^\s*ereject\s+text:(.*)\n\.\n;';
741             $patterns[] = '^\s*ereject\s+(.*?[^\\\]);';
742         }
743         if (in_array('vacation', $this->supported))
744             $patterns[] = '^\s*vacation\s+(.*?[^\\\]);';
745
746         $pattern = '/(' . implode('\s*$)|(', $patterns) . '$\s*)/ms';
747
748         // parse actions body
749         if (preg_match_all($pattern, $content, $mm, PREG_SET_ORDER)) {
750             foreach ($mm as $m) {
751                 $content = trim($m[0]);
752
753                 if(preg_match('/^(discard|keep|stop)/', $content, $matches)) {
754                     $result[] = array('type' => $matches[1]);
755                 }
756                 else if(preg_match('/^fileinto/', $content)) {
757                     $target = $m[sizeof($m)-1];
758                     $copy = false;
759                     if (preg_match('/^:copy\s+/', $target)) {
760                         $target = preg_replace('/^:copy\s+/', '', $target);
761                         $copy = true;
762                     }
763                     $result[] = array('type' => 'fileinto', 'copy' => $copy,
764                         'target' => $this->_parse_string($target));
765                 }
766                 else if(preg_match('/^redirect/', $content)) {
767                     $target = $m[sizeof($m)-1];
768                     $copy = false;
769                     if (preg_match('/^:copy\s+/', $target)) {
770                         $target = preg_replace('/^:copy\s+/', '', $target);
771                         $copy = true;
772                     }
773                     $result[] = array('type' => 'redirect', 'copy' => $copy,
774                         'target' => $this->_parse_string($target));
775                 }
776                 else if(preg_match('/^(reject|ereject)\s+(.*);$/sm', $content, $matches)) {
777                     $result[] = array('type' => $matches[1], 'target' => $this->_parse_string($matches[2]));
778                 }
779                 else if(preg_match('/^vacation\s+(.*);$/sm', $content, $matches)) {
780                     $vacation = array('type' => 'vacation');
781
782                     if (preg_match('/:days\s+([0-9]+)/', $content, $vm)) {
783                         $vacation['days'] = $vm[1];
784                         $content = preg_replace('/:days\s+([0-9]+)/', '', $content);
785                     }
786                     if (preg_match('/:subject\s+"(.*?[^\\\])"/', $content, $vm)) {
787                         $vacation['subject'] = $vm[1];
788                         $content = preg_replace('/:subject\s+"(.*?[^\\\])"/', '', $content);
789                     }
790                     if (preg_match('/:addresses\s+\[(.*?[^\\\])\]/', $content, $vm)) {
791                         $vacation['addresses'] = $this->_parse_list($vm[1]);
792                         $content = preg_replace('/:addresses\s+\[(.*?[^\\\])\]/', '', $content);
793                     }
794                     if (preg_match('/:handle\s+"(.*?[^\\\])"/', $content, $vm)) {
795                         $vacation['handle'] = $vm[1];
796                         $content = preg_replace('/:handle\s+"(.*?[^\\\])"/', '', $content);
797                     }
798                     if (preg_match('/:from\s+"(.*?[^\\\])"/', $content, $vm)) {
799                         $vacation['from'] = $vm[1];
800                         $content = preg_replace('/:from\s+"(.*?[^\\\])"/', '', $content);
801                     }
802
803                     $content = preg_replace('/^vacation/', '', $content);
804                     $content = preg_replace('/;$/', '', $content);
805                     $content = trim($content);
806
807                     if (preg_match('/^:mime/', $content, $vm)) {
808                         $vacation['mime'] = true;
809                         $content = preg_replace('/^:mime/', '', $content);
810                     }
811
812                     $vacation['reason'] = $this->_parse_string($content);
813
814                     $result[] = $vacation;
815                 }
816             }
817         }
818
819         return $result;
820     }
821
822     /**
823      * Parse test/conditions section
824      *
825      * @param string Text
826      */
827     private function _parse_tests($content)
828     {
829         $result = NULL;
830
831         // lists
832         if (preg_match('/^(allof|anyof)\s+\((.*)\)$/sm', $content, $matches)) {
833             $content = $matches[2];
834             $join = $matches[1]=='allof' ? true : false;
835         }
836         else
837             $join = false;
838
839         // supported tests regular expressions
840         // TODO: comparators, envelope
841         $patterns[] = '(not\s+)?(exists)\s+\[(.*?[^\\\])\]';
842         $patterns[] = '(not\s+)?(exists)\s+(".*?[^\\\]")';
843         $patterns[] = '(not\s+)?(true)';
844         $patterns[] = '(not\s+)?(size)\s+:(under|over)\s+([0-9]+[KGM]{0,1})';
845         $patterns[] = '(not\s+)?(header)\s+:(contains|is|matches)((\s+))\[(.*?[^\\\]")\]\s+\[(.*?[^\\\]")\]';
846         $patterns[] = '(not\s+)?(header)\s+:(contains|is|matches)((\s+))(".*?[^\\\]")\s+(".*?[^\\\]")';
847         $patterns[] = '(not\s+)?(header)\s+:(contains|is|matches)((\s+))\[(.*?[^\\\]")\]\s+(".*?[^\\\]")';
848         $patterns[] = '(not\s+)?(header)\s+:(contains|is|matches)((\s+))(".*?[^\\\]")\s+\[(.*?[^\\\]")\]';
849                 $patterns[] = '(not\s+)?(header)\s+:(count\s+"[gtleqn]{2}"|value\s+"[gtleqn]{2}")(\s+:comparator\s+"(.*?[^\\\])")?\s+\[(.*?[^\\\]")\]\s+\[(.*?[^\\\]")\]';
850                 $patterns[] = '(not\s+)?(header)\s+:(count\s+"[gtleqn]{2}"|value\s+"[gtleqn]{2}")(\s+:comparator\s+"(.*?[^\\\])")?\s+(".*?[^\\\]")\s+(".*?[^\\\]")';
851                 $patterns[] = '(not\s+)?(header)\s+:(count\s+"[gtleqn]{2}"|value\s+"[gtleqn]{2}")(\s+:comparator\s+"(.*?[^\\\])")?\s+\[(.*?[^\\\]")\]\s+(".*?[^\\\]")';
852                 $patterns[] = '(not\s+)?(header)\s+:(count\s+"[gtleqn]{2}"|value\s+"[gtleqn]{2}")(\s+:comparator\s+"(.*?[^\\\])")?\s+(".*?[^\\\]")\s+\[(.*?[^\\\]")\]';
853
854         // join patterns...
855         $pattern = '/(' . implode(')|(', $patterns) . ')/';
856
857         // ...and parse tests list
858         if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
859             foreach ($matches as $match) {
860                 $size = sizeof($match);
861
862                 if (preg_match('/^(not\s+)?size/', $match[0])) {
863                     $result[] = array(
864                         'test' => 'size',
865                         'not'  => $match[$size-4] ? true : false,
866                         'type' => $match[$size-2], // under/over
867                         'arg'  => $match[$size-1], // value
868                     );
869                 }
870                 else if (preg_match('/^(not\s+)?header/', $match[0])) {
871                     $type = $match[$size-5];
872                     if (preg_match('/^(count|value)\s+"([gtleqn]{2})"/', $type, $m))
873                         $type = $m[1] . '-' . $m[2];
874                     
875                     $result[] = array(
876                         'test' => 'header',
877                         'type' => $type, // is/contains/matches
878                                                 'not'  => $match[$size-7] ? true : false,
879                         'arg1' => $this->_parse_list($match[$size-2]), // header(s)
880                         'arg2' => $this->_parse_list($match[$size-1]), // string(s)
881                     );
882                 }
883                 else if (preg_match('/^(not\s+)?exists/', $match[0])) {
884                     $result[] = array(
885                         'test' => 'exists',
886                         'not'  => $match[$size-3] ? true : false,
887                         'arg'  => $this->_parse_list($match[$size-1]), // header(s)
888                     );
889                 }
890                 else if (preg_match('/^(not\s+)?true/', $match[0])) {
891                     $result[] = array(
892                         'test' => 'true',
893                         'not'  => $match[$size-2] ? true : false,
894                     );
895                 }
896             }
897         }
898
899         return array($result, $join);
900     }
901
902     /**
903      * Parse string value
904      *
905      * @param string Text
906      */
907     private function _parse_string($content)
908     {
909         $text = '';
910         $content = trim($content);
911
912         if (preg_match('/^text:(.*)\.$/sm', $content, $matches))
913             $text = trim($matches[1]);
914         else if (preg_match('/^"(.*)"$/', $content, $matches))
915             $text = str_replace('\"', '"', $matches[1]);
916
917         return $text;
918     }
919
920     /**
921      * Escape special chars in string value
922      *
923      * @param string Text
924      */
925     private function _escape_string($content)
926     {
927         $replace['/"/'] = '\\"';
928
929         if (is_array($content)) {
930             for ($x=0, $y=sizeof($content); $x<$y; $x++)
931                 $content[$x] = preg_replace(array_keys($replace),
932                     array_values($replace), $content[$x]);
933
934             return $content;
935         }
936         else
937             return preg_replace(array_keys($replace), array_values($replace), $content);
938     }
939
940     /**
941      * Parse string or list of strings to string or array of strings
942      *
943      * @param string Text
944      */
945     private function _parse_list($content)
946     {
947         $result = array();
948
949         for ($x=0, $len=strlen($content); $x<$len; $x++) {
950             switch ($content[$x]) {
951             case '\\':
952                 $str .= $content[++$x];
953                 break;
954             case '"':
955                 if (isset($str)) {
956                     $result[] = $str;
957                     unset($str);
958                 }
959                 else
960                     $str = '';
961                 break;
962             default:
963                 if(isset($str))
964                     $str .= $content[$x];
965             break;
966             }
967         }
968
969         if (sizeof($result)>1)
970             return $result;
971         else if (sizeof($result) == 1)
972             return $result[0];
973         else
974             return NULL;
975     }
976
977     /**
978      * Convert array of elements to list of strings
979      *
980      * @param string Text
981      */
982     private function _print_list($list)
983     {
984         $list = (array) $list;
985         foreach($list as $idx => $val)
986             $list[$idx] = $this->_escape_string($val);
987
988         return '["' . implode('","', $list) . '"]';
989     }
990 }