1<?php
2/**
3 * Copyright 2002-2003 Richard Heyes
4 * Copyright 2006-2008 Anish Mistry
5 * Copyright 2009-2017 Horde LLC (http://www.horde.org/)
6 *
7 * See the enclosed file LICENSE for license information (BSD). If you
8 * did not receive this file, see http://www.horde.org/licenses/bsd.
9 *
10 * @package   ManageSieve
11 * @author    Richard Heyes <richard@phpguru.org>
12 * @author    Damian Fernandez Sosa <damlists@cnba.uba.ar>
13 * @author    Anish Mistry <amistry@am-productions.biz>
14 * @author    Jan Schneider <jan@horde.org>
15 * @license   http://www.horde.org/licenses/bsd BSD
16 */
17
18namespace Horde;
19use Auth_SASL;
20use Horde\Socket\Client;
21use Horde\ManageSieve\Exception;
22
23/**
24 * This class implements the ManageSieve protocol (RFC 5804).
25 *
26 * @package   ManageSieve
27 * @author    Richard Heyes <richard@phpguru.org>
28 * @author    Damian Fernandez Sosa <damlists@cnba.uba.ar>
29 * @author    Anish Mistry <amistry@am-productions.biz>
30 * @author    Jan Schneider <jan@horde.org>
31 * @copyright 2002-2003 Richard Heyes
32 * @copyright 2006-2008 Anish Mistry
33 * @copyright 2009-2017 Horde LLC
34 * @license   http://www.horde.org/licenses/bsd BSD
35 * @link      http://tools.ietf.org/html/rfc5804 RFC 5804 A Protocol for
36 *            Remotely Managing Sieve Scripts
37 */
38class ManageSieve
39{
40    /**
41     * Client is disconnected.
42     */
43    const STATE_DISCONNECTED = 1;
44
45    /**
46     * Client is connected but not authenticated.
47     */
48    const STATE_NON_AUTHENTICATED = 2;
49
50    /**
51     * Client is authenticated.
52     */
53    const STATE_AUTHENTICATED = 3;
54
55    /**
56     * Authentication with the best available method.
57     */
58    const AUTH_AUTOMATIC = 0;
59
60    /**
61     * DIGEST-MD5 authentication.
62     */
63    const AUTH_DIGESTMD5 = 'DIGEST-MD5';
64
65    /**
66     * CRAM-MD5 authentication.
67     */
68    const AUTH_CRAMMD5 = 'CRAM-MD5';
69
70    /**
71     * LOGIN authentication.
72     */
73    const AUTH_LOGIN = 'LOGIN';
74
75    /**
76     * PLAIN authentication.
77     */
78    const AUTH_PLAIN = 'PLAIN';
79
80    /**
81     * EXTERNAL authentication.
82     */
83    const AUTH_EXTERNAL = 'EXTERNAL';
84
85    /**
86     * The authentication methods this class supports.
87     *
88     * Can be overwritten if having problems with certain methods.
89     *
90     * @var array
91     */
92    public $supportedAuthMethods = array(
93        self::AUTH_DIGESTMD5,
94        self::AUTH_CRAMMD5,
95        self::AUTH_EXTERNAL,
96        self::AUTH_PLAIN,
97        self::AUTH_LOGIN,
98    );
99
100    /**
101     * SASL authentication methods that require Auth_SASL.
102     *
103     * @var array
104     */
105    public $supportedSASLAuthMethods = array(
106        self::AUTH_DIGESTMD5,
107        self::AUTH_CRAMMD5,
108    );
109
110    /**
111     * The socket client.
112     *
113     * @var \Horde\Socket\Client
114     */
115    protected $_sock;
116
117    /**
118     * Parameters and connection information.
119     *
120     * @var array
121     */
122    protected $_params;
123
124    /**
125     * Current state of the connection.
126     *
127     * One of the STATE_* constants.
128     *
129     * @var integer
130     */
131    protected $_state = self::STATE_DISCONNECTED;
132
133    /**
134     * Logging handler.
135     *
136     * @var string|array
137     */
138    protected $_logger;
139
140    /**
141     * Maximum number of referral loops
142     *
143     * @var array
144     */
145    protected $_maxReferralCount = 15;
146
147    /**
148     * Constructor.
149     *
150     * If username and password are provided connects to the server and logs
151     * in too.
152     *
153     * @param array $params  A hash of connection parameters:
154     *   - host: Hostname of server (DEFAULT: localhost). Optionally prefixed
155     *           with protocol scheme.
156     *   - port: Port of server (DEFAULT: 4190).
157     *   - user: Login username (optional).
158     *   - password: Login password (optional).
159     *   - authmethod: Type of login to perform (see $supportedAuthMethods)
160     *                 (DEFAULT: AUTH_AUTOMATIC).
161     *   - euser: Effective user. If authenticating as an administrator, login
162     *            as this user.
163     *   - bypassauth: Skip the authentication phase. Useful if passing an
164     *                 already open socket.
165     *   - secure: Security layer requested. One of:
166     *     - true: (TLS if available/necessary) [DEFAULT]
167     *     - false: (No encryption)
168     *     - 'ssl': (Auto-detect SSL version)
169     *     - 'sslv2': (Force SSL version 3)
170     *     - 'sslv3': (Force SSL version 2)
171     *     - 'tls': (TLS; started via protocol-level negotation over
172     *              unencrypted channel)
173     *     - 'tlsv1': (TLS version 1.x connection)
174     *   - context: Additional options for stream_context_create().
175     *   - logger: A log handler, must implement debug().
176     *
177     * @throws \Horde\ManageSieve\Exception
178     */
179    public function __construct($params = array())
180    {
181        $this->_params = array_merge(
182            array(
183                'authmethod' => self::AUTH_AUTOMATIC,
184                'bypassauth' => false,
185                'context'    => array(),
186                'euser'      => null,
187                'host'       => 'localhost',
188                'logger'     => null,
189                'password'   => '',
190                'port'       => 4190,
191                'secure'     => true,
192                'timeout'    => 5,
193                'user'       => '',
194            ),
195            $params
196        );
197
198        /* Try to include the Auth_SASL package.  If the package is not
199         * available, we disable the authentication methods that depend upon
200         * it. */
201        if (!class_exists('Auth_SASL')) {
202            $this->_debug('Auth_SASL not present');
203            $this->supportedAuthMethods = array_diff(
204                $this->supportedAuthMethods,
205                $this->supportedSASLAuthMethods
206            );
207        }
208
209        if ($this->_params['logger']) {
210            $this->setLogger($this->_params['logger']);
211        }
212
213        if (strlen($this->_params['user']) &&
214            strlen($this->_params['password'])) {
215            $this->_handleConnectAndLogin();
216        }
217    }
218
219    /**
220     * Passes a logger for debug logging.
221     *
222     * @param object $logger   A log handler, must implement debug().
223     */
224    public function setLogger($logger)
225    {
226        $this->_logger = $logger;
227    }
228
229    /**
230     * Connects to the server and logs in.
231     *
232     * @throws \Horde\ManageSieve\Exception
233     */
234    protected function _handleConnectAndLogin()
235    {
236        $this->connect(
237            $this->_params['host'],
238            $this->_params['port'],
239            $this->_params['context'],
240            $this->_params['secure']
241        );
242        if (!$this->_params['bypassauth']) {
243            $this->login(
244                $this->_params['user'],
245                $this->_params['password'],
246                $this->_params['authmethod'],
247                $this->_params['euser']
248            );
249        }
250    }
251
252    /**
253     * Handles connecting to the server and checks the response validity.
254     *
255     * Defaults from the constructor are used for missing parameters.
256     *
257     * @param string  $host    Hostname of server.
258     * @param string  $port    Port of server.
259     * @param array   $context List of options to pass to
260     *                         stream_context_create().
261     * @param boolean $secure Security layer requested. @see __construct().
262     *
263     * @throws \Horde\ManageSieve\Exception
264     */
265    public function connect(
266        $host = null, $port = null, $context = null, $secure = null
267    )
268    {
269        if (isset($host)) {
270            $this->_params['host'] = $host;
271        }
272        if (isset($port)) {
273            $this->_params['port'] = $port;
274        }
275        if (isset($context)) {
276            $this->_params['context'] = array_replace_recursive(
277                $this->_params['context'],
278                $context
279            );
280        }
281        if (isset($secure)) {
282            $this->_params['secure'] = $secure;
283        }
284
285        if (self::STATE_DISCONNECTED != $this->_state) {
286            throw new Exception\NotDisconnected();
287        }
288
289        try {
290            $this->_sock = new Client(
291                $this->_params['host'],
292                $this->_params['port'],
293                $this->_params['timeout'],
294                $this->_params['secure'],
295                $this->_params['context']
296            );
297        } catch (Client\Exception $e) {
298            throw new Exception\ConnectionFailed($e);
299        }
300
301        if ($this->_params['bypassauth']) {
302            $this->_state = self::STATE_AUTHENTICATED;
303        } else {
304            $this->_state = self::STATE_NON_AUTHENTICATED;
305            $this->_doCmd();
306        }
307
308        // Explicitly ask for the capabilities in case the connection is
309        // picked up from an existing connection.
310        try {
311            $this->_cmdCapability();
312        } catch (Exception $e) {
313            throw new Exception\ConnectionFailed($e);
314        }
315
316        // Check if we can enable TLS via STARTTLS.
317        if ($this->_params['secure'] === 'tls' ||
318            ($this->_params['secure'] === true &&
319             !empty($this->_capability['starttls']))) {
320            $this->_doCmd('STARTTLS');
321            if (!$this->_sock->startTls()) {
322                throw new Exception('Failed to establish TLS connection');
323            }
324
325            // The server should be sending a CAPABILITY response after
326            // negotiating TLS. Read it, and ignore if it doesn't.
327            // Unfortunately old Cyrus versions are broken and don't send a
328            // CAPABILITY response, thus we would wait here forever. Parse the
329            // Cyrus version and work around this broken behavior.
330            if (!preg_match('/^CYRUS TIMSIEVED V([0-9.]+)/', $this->_capability['implementation'], $matches) ||
331                version_compare($matches[1], '2.3.10', '>=')) {
332                $this->_doCmd();
333            }
334
335            // Query the server capabilities again now that we are under
336            // encryption.
337            try {
338                $this->_cmdCapability();
339            } catch (Exception $e) {
340                throw new Exception\ConnectionFailed($e);
341            }
342        }
343    }
344
345    /**
346     * Disconnect from the Sieve server.
347     *
348     * @param boolean $sendLogoutCMD  Whether to send LOGOUT command before
349     *                                disconnecting.
350     *
351     * @throws \Horde\ManageSieve\Exception
352     */
353    public function disconnect($sendLogoutCMD = true)
354    {
355        $this->_cmdLogout($sendLogoutCMD);
356    }
357
358    /**
359     * Logs into server.
360     *
361     * Defaults from the constructor are used for missing parameters.
362     *
363     * @param string $user        Login username.
364     * @param string $password    Login password.
365     * @param string $authmethod  Type of login method to use.
366     * @param string $euser       Effective UID (perform on behalf of $euser).
367     *
368     * @throws \Horde\ManageSieve\Exception
369     */
370    public function login(
371        $user = null, $password = null, $authmethod = null, $euser = null
372    )
373    {
374        if (isset($user)) {
375            $this->_params['user'] = $user;
376        }
377        if (isset($password)) {
378            $this->_params['password'] = $password;
379        }
380        if (isset($authmethod)) {
381            $this->_params['authmethod'] = $authmethod;
382        }
383        if (isset($euser)) {
384            $this->_params['euser'] = $euser;
385        }
386
387        $this->_checkConnected();
388        if (self::STATE_AUTHENTICATED == $this->_state) {
389            throw new Exception('Already authenticated');
390        }
391
392        $this->_cmdAuthenticate(
393            $this->_params['user'],
394            $this->_params['password'],
395            $this->_params['authmethod'],
396            $this->_params['euser']
397        );
398        $this->_state = self::STATE_AUTHENTICATED;
399    }
400
401    /**
402     * Returns an indexed array of scripts currently on the server.
403     *
404     * @return array  Indexed array of scriptnames.
405     */
406    public function listScripts()
407    {
408        if (is_array($scripts = $this->_cmdListScripts())) {
409            return $scripts[0];
410        } else {
411            return $scripts;
412        }
413    }
414
415    /**
416     * Returns the active script.
417     *
418     * @return string  The active scriptname.
419     */
420    public function getActive()
421    {
422        if (is_array($scripts = $this->_cmdListScripts())) {
423            return $scripts[1];
424        }
425    }
426
427    /**
428     * Sets the active script.
429     *
430     * @param string $scriptname The name of the script to be set as active.
431     *
432     * @throws \Horde\ManageSieve\Exception
433     */
434    public function setActive($scriptname)
435    {
436        $this->_cmdSetActive($scriptname);
437    }
438
439    /**
440     * Retrieves a script.
441     *
442     * @param string $scriptname The name of the script to be retrieved.
443     *
444     * @throws \Horde\ManageSieve\Exception
445     * @return string  The script.
446    */
447    public function getScript($scriptname)
448    {
449        return $this->_cmdGetScript($scriptname);
450    }
451
452    /**
453     * Adds a script to the server.
454     *
455     * @param string  $scriptname Name of the script.
456     * @param string  $script     The script content.
457     * @param boolean $makeactive Whether to make this the active script.
458     *
459     * @throws \Horde\ManageSieve\Exception
460     */
461    public function installScript($scriptname, $script, $makeactive = false)
462    {
463        $this->_cmdPutScript($scriptname, $script);
464        if ($makeactive) {
465            $this->_cmdSetActive($scriptname);
466        }
467    }
468
469    /**
470     * Removes a script from the server.
471     *
472     * @param string $scriptname Name of the script.
473     *
474     * @throws \Horde\ManageSieve\Exception
475     */
476    public function removeScript($scriptname)
477    {
478        $this->_cmdDeleteScript($scriptname);
479    }
480
481    /**
482     * Checks if the server has space to store the script by the server.
483     *
484     * @param string  $scriptname The name of the script to mark as active.
485     * @param integer $size       The size of the script.
486     *
487     * @throws \Horde\ManageSieve\Exception
488     * @return boolean  True if there is space.
489     */
490    public function hasSpace($scriptname, $size)
491    {
492        $this->_checkAuthenticated();
493
494        try {
495            $this->_doCmd(
496                sprintf('HAVESPACE %s %d', $this->_escape($scriptname), $size)
497            );
498        } catch (Exception $e) {
499            return false;
500        }
501
502        return true;
503    }
504
505    /**
506     * Returns the list of extensions the server supports.
507     *
508     * @throws \Horde\ManageSieve\Exception
509     * @return array  List of extensions.
510     */
511    public function getExtensions()
512    {
513        $this->_checkConnected();
514        return $this->_capability['extensions'];
515    }
516
517    /**
518     * Returns whether the server supports an extension.
519     *
520     * @param string $extension The extension to check.
521     *
522     * @throws \Horde\ManageSieve\Exception
523     * @return boolean  Whether the extension is supported.
524     */
525    public function hasExtension($extension)
526    {
527        $this->_checkConnected();
528
529        $extension = trim(\Horde_String::upper($extension));
530        if (is_array($this->_capability['extensions'])) {
531            foreach ($this->_capability['extensions'] as $ext) {
532                if ($ext == $extension) {
533                    return true;
534                }
535            }
536        }
537
538        return false;
539    }
540
541    /**
542     * Returns the list of authentication methods the server supports.
543     *
544     * @throws \Horde\ManageSieve\Exception
545     * @return array  List of authentication methods.
546     */
547    public function getAuthMechs()
548    {
549        $this->_checkConnected();
550        return $this->_capability['sasl'];
551    }
552
553    /**
554     * Returns whether the server supports an authentication method.
555     *
556     * @param string $method The method to check.
557     *
558     * @throws \Horde\ManageSieve\Exception
559     * @return boolean  Whether the method is supported.
560     */
561    public function hasAuthMech($method)
562    {
563        $this->_checkConnected();
564
565        $method = trim(\Horde_String::upper($method));
566        if (is_array($this->_capability['sasl'])) {
567            foreach ($this->_capability['sasl'] as $sasl) {
568                if ($sasl == $method) {
569                    return true;
570                }
571            }
572        }
573
574        return false;
575    }
576
577    /**
578     * Handles the authentication using any known method.
579     *
580     * @param string $uid        The userid to authenticate as.
581     * @param string $pwd        The password to authenticate with.
582     * @param string $authmethod The method to use. If empty, the class chooses
583     *                           the best (strongest) available method.
584     * @param string $euser      The effective uid to authenticate as.
585     *
586     * @throws \Horde\ManageSieve\Exception
587     */
588    protected function _cmdAuthenticate(
589        $uid, $pwd, $authmethod = null, $euser = ''
590    )
591    {
592        $method = $this->_getBestAuthMethod($authmethod);
593
594        switch ($method) {
595        case self::AUTH_DIGESTMD5:
596            $this->_authDigestMD5($uid, $pwd, $euser);
597            return;
598        case self::AUTH_CRAMMD5:
599            $this->_authCRAMMD5($uid, $pwd, $euser);
600            break;
601        case self::AUTH_LOGIN:
602            $this->_authLOGIN($uid, $pwd, $euser);
603            break;
604        case self::AUTH_PLAIN:
605            $this->_authPLAIN($uid, $pwd, $euser);
606            break;
607        case self::AUTH_EXTERNAL:
608            $this->_authEXTERNAL($uid, $pwd, $euser);
609            break;
610        default :
611            throw new Exception(
612                $method . ' is not a supported authentication method'
613            );
614            break;
615        }
616
617        $this->_doCmd();
618
619        // Query the server capabilities again now that we are authenticated.
620        try {
621            $this->_cmdCapability();
622        } catch (Exception $e) {
623            throw new Exception\ConnectionFailed($e);
624        }
625    }
626
627    /**
628     * Authenticates the user using the PLAIN method.
629     *
630     * @param string $user  The userid to authenticate as.
631     * @param string $pass  The password to authenticate with.
632     * @param string $euser The effective uid to authenticate as.
633     *
634     * @throws \Horde\ManageSieve\Exception
635     */
636    protected function _authPLAIN($user, $pass, $euser)
637    {
638        return $this->_sendCmd(
639            sprintf(
640                'AUTHENTICATE "PLAIN" "%s"',
641                base64_encode($euser . chr(0) . $user . chr(0) . $pass)
642            )
643        );
644    }
645
646    /**
647     * Authenticates the user using the LOGIN method.
648     *
649     * @param string $user  The userid to authenticate as.
650     * @param string $pass  The password to authenticate with.
651     * @param string $euser The effective uid to authenticate as. Not used.
652     *
653     * @throws \Horde\ManageSieve\Exception
654     */
655    protected function _authLOGIN($user, $pass, $euser)
656    {
657        $this->_sendCmd('AUTHENTICATE "LOGIN"');
658        $this->_doCmd('"' . base64_encode($user) . '"', true);
659        $this->_doCmd('"' . base64_encode($pass) . '"', true);
660    }
661
662    /**
663     * Authenticates the user using the CRAM-MD5 method.
664     *
665     * @param string $user  The userid to authenticate as.
666     * @param string $pass  The password to authenticate with.
667     * @param string $euser The effective uid to authenticate as. Not used.
668     *
669     * @throws \Horde\ManageSieve\Exception
670     */
671    protected function _authCRAMMD5($user, $pass, $euser)
672    {
673        $challenge = $this->_doCmd('AUTHENTICATE "CRAM-MD5"', true);
674        $challenge = base64_decode(trim($challenge));
675        $cram = Auth_SASL::factory('crammd5');
676        $response = $cram->getResponse($user, $pass, $challenge);
677        if (is_a($response, 'PEAR_Error')) {
678            throw new Exception($response);
679        }
680        $this->_sendStringResponse(base64_encode($response));
681    }
682
683    /**
684     * Authenticates the user using the DIGEST-MD5 method.
685     *
686     * @param string $user  The userid to authenticate as.
687     * @param string $pass  The password to authenticate with.
688     * @param string $euser The effective uid to authenticate as.
689     *
690     * @throws \Horde\ManageSieve\Exception
691     */
692    protected function _authDigestMD5($user, $pass, $euser)
693    {
694        $challenge = $this->_doCmd('AUTHENTICATE "DIGEST-MD5"', true);
695        $challenge = base64_decode(trim($challenge));
696        $digest = Auth_SASL::factory('digestmd5');
697        // @todo Really 'localhost'?
698        $response = $digest->getResponse(
699            $user, $pass, $challenge, 'localhost', 'sieve', $euser
700        );
701        if (is_a($response, 'PEAR_Error')) {
702            throw new Exception($response);
703        }
704
705        $this->_sendStringResponse(base64_encode($response));
706        $this->_doCmd('', true);
707        if (\Horde_String::upper(substr($result, 0, 2)) == 'OK') {
708            return;
709        }
710
711        /* We don't use the protocol's third step because SIEVE doesn't allow
712         * subsequent authentication, so we just silently ignore it. */
713        $this->_sendStringResponse('');
714        $this->_doCmd();
715    }
716
717    /**
718     * Authenticates the user using the EXTERNAL method.
719     *
720     * @param string $user  The userid to authenticate as.
721     * @param string $pass  The password to authenticate with.
722     * @param string $euser The effective uid to authenticate as.
723     *
724     * @throws \Horde\ManageSieve\Exception
725     */
726    protected function _authEXTERNAL($user, $pass, $euser)
727    {
728        $cmd = sprintf(
729            'AUTHENTICATE "EXTERNAL" "%s"',
730            base64_encode(strlen($euser) ? $euser : $user)
731        );
732        return $this->_sendCmd($cmd);
733    }
734
735    /**
736     * Removes a script from the server.
737     *
738     * @param string $scriptname Name of the script to delete.
739     *
740     * @throws \Horde\ManageSieve\Exception
741     */
742    protected function _cmdDeleteScript($scriptname)
743    {
744        $this->_checkAuthenticated();
745        $this->_doCmd(sprintf('DELETESCRIPT %s', $this->_escape($scriptname)));
746    }
747
748    /**
749     * Retrieves the contents of the named script.
750     *
751     * @param string $scriptname Name of the script to retrieve.
752     *
753     * @throws \Horde\ManageSieve\Exception
754     * @return string  The script.
755     */
756    protected function _cmdGetScript($scriptname)
757    {
758        $this->_checkAuthenticated();
759        $result = $this->_doCmd(
760            sprintf('GETSCRIPT %s', $this->_escape($scriptname))
761        );
762        return preg_replace('/^{[0-9]+}\r\n/', '', $result);
763    }
764
765    /**
766     * Sets the active script, i.e. the one that gets run on new mail by the
767     * server.
768     *
769     * @param string $scriptname The name of the script to mark as active.
770     *
771     * @throws \Horde\ManageSieve\Exception
772     */
773    protected function _cmdSetActive($scriptname)
774    {
775        $this->_checkAuthenticated();
776        $this->_doCmd(sprintf('SETACTIVE %s', $this->_escape($scriptname)));
777    }
778
779    /**
780     * Returns the list of scripts on the server.
781     *
782     * @throws \Horde\ManageSieve\Exception
783     * @return array  An array with the list of scripts in the first element
784     *                and the active script in the second element.
785     */
786    protected function _cmdListScripts()
787    {
788        $this->_checkAuthenticated();
789
790        $result = $this->_doCmd('LISTSCRIPTS');
791
792        $scripts = array();
793        $activescript = null;
794        $result = explode("\r\n", $result);
795        foreach ($result as $value) {
796            if (preg_match('/^"(.*)"( ACTIVE)?$/i', $value, $matches)) {
797                $script_name = stripslashes($matches[1]);
798                $scripts[] = $script_name;
799                if (!empty($matches[2])) {
800                    $activescript = $script_name;
801                }
802            }
803        }
804
805        return array($scripts, $activescript);
806    }
807
808    /**
809     * Adds a script to the server.
810     *
811     * @param string $scriptname Name of the new script.
812     * @param string $scriptdata The new script.
813     *
814     * @throws \Horde\ManageSieve\Exception
815     */
816    protected function _cmdPutScript($scriptname, $scriptdata)
817    {
818        $this->_checkAuthenticated();
819        $command = sprintf(
820            "PUTSCRIPT %s {%d+}\r\n%s",
821            $this->_escape($scriptname),
822            strlen($scriptdata),
823            $scriptdata
824        );
825        $this->_doCmd($command);
826    }
827
828    /**
829     * Logs out of the server and terminates the connection.
830     *
831     * @param boolean $sendLogoutCMD Whether to send LOGOUT command before
832     *                               disconnecting.
833     *
834     * @throws \Horde\ManageSieve\Exception
835     */
836    protected function _cmdLogout($sendLogoutCMD = true)
837    {
838        $this->_checkConnected();
839        if ($sendLogoutCMD) {
840            $this->_doCmd('LOGOUT');
841        }
842        $this->_sock->close();
843        $this->_state = self::STATE_DISCONNECTED;
844    }
845
846    /**
847     * Sends the CAPABILITY command
848     *
849     * @throws \Horde\ManageSieve\Exception
850     */
851    protected function _cmdCapability()
852    {
853        $this->_checkConnected();
854        $result = $this->_doCmd('CAPABILITY');
855        $this->_parseCapability($result);
856    }
857
858    /**
859     * Parses the response from the CAPABILITY command and stores the result
860     * in $_capability.
861     *
862     * @param string $data The response from the capability command.
863     */
864    protected function _parseCapability($data)
865    {
866        // Clear the cached capabilities.
867        $this->_capability = array(
868            'sasl' => array(),
869            'extensions' => array()
870        );
871
872        $data = preg_split(
873            '/\r?\n/',
874            \Horde_String::upper($data),
875            -1,
876            PREG_SPLIT_NO_EMPTY
877        );
878
879        for ($i = 0; $i < count($data); $i++) {
880            if (!preg_match('/^"([A-Z]+)"( "(.*)")?$/', $data[$i], $matches)) {
881                continue;
882            }
883            switch ($matches[1]) {
884            case 'IMPLEMENTATION':
885                $this->_capability['implementation'] = $matches[3];
886                break;
887
888            case 'SASL':
889                $this->_capability['sasl'] = preg_split('/\s+/', $matches[3]);
890                break;
891
892            case 'SIEVE':
893                $this->_capability['extensions'] = preg_split('/\s+/', $matches[3]);
894                break;
895
896            case 'STARTTLS':
897                $this->_capability['starttls'] = true;
898                break;
899            }
900        }
901    }
902
903    /**
904     * Sends a command to the server
905     *
906     * @param string $cmd The command to send.
907     */
908    protected function _sendCmd($cmd)
909    {
910        $status = $this->_sock->getStatus();
911        if ($status['eof']) {
912            throw new Exception('Failed to write to socket: connection lost');
913        }
914        $this->_sock->write($cmd . "\r\n");
915        $this->_debug("C: $cmd");
916    }
917
918    /**
919     * Sends a string response to the server.
920     *
921     * @param string $str The string to send.
922     */
923    protected function _sendStringResponse($str)
924    {
925        return $this->_sendCmd('{' . strlen($str) . "+}\r\n" . $str);
926    }
927
928    /**
929     * Receives a single line from the server.
930     *
931     * @return string  The server response line.
932     */
933    protected function _recvLn()
934    {
935        $lastline = rtrim($this->_sock->gets(8192));
936        $this->_debug("S: $lastline");
937        if ($lastline === '') {
938            throw new Exception('Failed to read from socket');
939        }
940        return $lastline;
941    }
942
943    /**
944     * Receives a number of bytes from the server.
945     *
946     * @param integer $length  Number of bytes to read.
947     *
948     * @return string  The server response.
949     */
950    protected function _recvBytes($length)
951    {
952        $response = '';
953        $response_length = 0;
954        while ($response_length < $length) {
955            $response .= $this->_sock->read($length - $response_length);
956            $response_length = strlen($response);
957        }
958        $this->_debug('S: ' . rtrim($response));
959        return $response;
960    }
961
962    /**
963     * Send a command and retrieves a response from the server.
964     *
965     * @param string $cmd   The command to send.
966     * @param boolean $auth Whether this is an authentication command.
967     *
968     * @throws \Horde\ManageSieve\Exception if a NO response.
969     * @return string  Reponse string if an OK response.
970     *
971     */
972    protected function _doCmd($cmd = '', $auth = false)
973    {
974        $referralCount = 0;
975        while ($referralCount < $this->_maxReferralCount) {
976            if (strlen($cmd)) {
977                $this->_sendCmd($cmd);
978            }
979
980            $response = '';
981            while (true) {
982                $line = $this->_recvLn();
983
984                if (preg_match('/^(OK|NO)/i', $line, $tag)) {
985                    // Check for string literal message.
986                    // DBMail has some broken versions that send the trailing
987                    // plus even though it's disallowed.
988                    if (preg_match('/{([0-9]+)\+?}$/', $line, $matches)) {
989                        $line = substr($line, 0, -(strlen($matches[1]) + 2))
990                            . str_replace(
991                                "\r\n", ' ', $this->_recvBytes($matches[1] + 2)
992                            );
993                    }
994
995                    if ('OK' == \Horde_String::upper($tag[1])) {
996                        $response .= $line;
997                        return rtrim($response);
998                    }
999
1000                    throw new Exception(trim($response . substr($line, 2)), 3);
1001                }
1002
1003                if (preg_match('/^BYE/i', $line)) {
1004                    try {
1005                        $this->disconnect(false);
1006                    } catch (Exception $e) {
1007                        throw new Exception(
1008                            'Cannot handle BYE, the error was: '
1009                            . $e->getMessage(),
1010                            4
1011                        );
1012                    }
1013                    // Check for referral, then follow it.  Otherwise, carp an
1014                    // error.
1015                    if (preg_match('/^bye \(referral "(sieve:\/\/)?([^"]+)/i', $line, $matches)) {
1016                        // Replace the old host with the referral host
1017                        // preserving any protocol prefix.
1018                        $this->_params['host'] = preg_replace(
1019                            '/\w+(?!(\w|\:\/\/)).*/', $matches[2],
1020                            $this->_params['host']
1021                        );
1022                        try {
1023                            $this->_handleConnectAndLogin();
1024                        } catch (Exception $e) {
1025                            throw new Exception\Referral(
1026                                'Cannot follow referral to '
1027                                . $this->_params['host'] . ', the error was: '
1028                                . $e->getMessage()
1029                            );
1030                        }
1031                        break;
1032                    }
1033                    throw new Exception(trim($response . $line), 6);
1034                }
1035
1036                if (preg_match('/^{([0-9]+)}/', $line, $matches)) {
1037                    // Matches literal string responses.
1038                    $line = $this->_recvBytes($matches[1] + 2);
1039                    if (!$auth) {
1040                        // Receive the pending OK only if we aren't
1041                        // authenticating since string responses during
1042                        // authentication don't need an OK.
1043                        $this->_recvLn();
1044                    }
1045                    return $line;
1046                }
1047
1048                if ($auth) {
1049                    // String responses during authentication don't need an
1050                    // OK.
1051                    $response .= $line;
1052                    return rtrim($response);
1053                }
1054
1055                $response .= $line . "\r\n";
1056                $referralCount++;
1057            }
1058        }
1059
1060        throw new Exception\Referral('Max referral count (' . $referralCount . ') reached.');
1061    }
1062
1063    /**
1064     * Returns the name of the best authentication method that the server
1065     * has advertised.
1066     *
1067     * @param string $authmethod Only consider this method as available.
1068     *
1069     * @throws \Horde\ManageSieve\Exception
1070     * @return string  The name of the best supported authentication method.
1071     */
1072    protected function _getBestAuthMethod($authmethod = null)
1073    {
1074        if (!isset($this->_capability['sasl'])) {
1075            throw new Exception(
1076                'This server doesn\'t support any authentication methods. SASL problem?'
1077            );
1078        }
1079        if (!$this->_capability['sasl']) {
1080            throw new Exception(
1081                'This server doesn\'t support any authentication methods.'
1082            );
1083        }
1084
1085        if ($authmethod) {
1086            if (in_array($authmethod, $this->_capability['sasl'])) {
1087                return $authmethod;
1088            }
1089            throw new Exception(
1090                sprintf(
1091                    'No supported authentication method found. The server supports these methods: %s, but we want to use: %s',
1092                    implode(', ', $this->_capability['sasl']),
1093                    $authmethod
1094                )
1095            );
1096        }
1097
1098        foreach ($this->supportedAuthMethods as $method) {
1099            if (in_array($method, $this->_capability['sasl'])) {
1100                return $method;
1101            }
1102        }
1103
1104        throw new Exception(
1105            sprintf(
1106                'No supported authentication method found. The server supports these methods: %s, but we only support: %s',
1107                implode(', ', $this->_capability['sasl']),
1108                implode(', ', $this->supportedAuthMethods)
1109            )
1110        );
1111    }
1112
1113    /**
1114     * Asserts that the client is in disconnected state.
1115     *
1116     * @throws \Horde\ManageSieve\Exception
1117     */
1118    protected function _checkConnected()
1119    {
1120        if (self::STATE_DISCONNECTED == $this->_state) {
1121            throw new Exception\NotConnected();
1122        }
1123    }
1124
1125    /**
1126     * Asserts that the client is in authenticated state.
1127     *
1128     * @throws \Horde\ManageSieve\Exception
1129     */
1130    protected function _checkAuthenticated()
1131    {
1132        if (self::STATE_AUTHENTICATED != $this->_state) {
1133            throw new Exception\NotAuthenticated();
1134        }
1135    }
1136
1137    /**
1138     * Converts strings into RFC's quoted-string or literal-c2s form.
1139     *
1140     * @param string $string  The string to convert.
1141     *
1142     * @return string  Result string.
1143     */
1144    protected function _escape($string)
1145    {
1146        // Some implementations don't allow UTF-8 characters in quoted-string,
1147        // use literal-c2s.
1148        if (preg_match('/[^\x01-\x09\x0B-\x0C\x0E-\x7F]/', $string)) {
1149            return sprintf("{%d+}\r\n%s", strlen($string), $string);
1150        }
1151
1152        return '"' . addcslashes($string, '\\"') . '"';
1153    }
1154
1155    /**
1156     * Write debug text to the current log handler.
1157     *
1158     * @param string $message  Debug message text.
1159     */
1160    protected function _debug($message)
1161    {
1162        if ($this->_logger) {
1163            $this->_logger->debug($message);
1164        }
1165    }
1166}
1167