PageRenderTime 40ms CodeModel.GetById 14ms RepoModel.GetById 0ms app.codeStats 0ms

/core/src/main/php/peer/sieve/SieveClient.class.php

http://github.com/xp-framework/xp-framework
PHP | 479 lines | 206 code | 49 blank | 224 comment | 36 complexity | 824db6263cfa2fef36514862c5ce4086 MD5 | raw file
Possible License(s): BSD-3-Clause
  1. <?php
  2. /* This class is part of the XP framework
  3. *
  4. * $Id$
  5. */
  6. uses(
  7. 'peer.Socket',
  8. 'peer.sieve.SieveScript',
  9. 'security.checksum.HMAC_MD5',
  10. 'security.sasl.DigestChallenge',
  11. 'util.log.Traceable'
  12. );
  13. // Authentication methods
  14. define('SIEVE_SASL_PLAIN', 'PLAIN');
  15. define('SIEVE_SASL_LOGIN', 'LOGIN');
  16. define('SIEVE_SASL_KERBEROS_V4', 'KERBEROS_V4');
  17. define('SIEVE_SASL_DIGEST_MD5', 'DIGEST-MD5');
  18. define('SIEVE_SASL_CRAM_MD5', 'CRAM-MD5');
  19. // Modules
  20. define('SIEVE_MOD_FILEINTO', 'FILEINTO');
  21. define('SIEVE_MOD_REJECT', 'REJECT');
  22. define('SIEVE_MOD_ENVELOPE', 'ENVELOPE');
  23. define('SIEVE_MOD_VACATION', 'VACATION');
  24. define('SIEVE_MOD_IMAPFLAGS', 'IMAPFLAGS');
  25. define('SIEVE_MOD_NOTIFY', 'NOTIFY');
  26. define('SIEVE_MOD_SUBADDRESS', 'SUBADDRESS');
  27. define('SIEVE_MOD_REGEX', 'REGEX');
  28. /**
  29. * Sieve is a mail filtering language
  30. *
  31. * Usage example [listing all available scripts]:
  32. * <code>
  33. * uses('peer.sieve.SieveClient');
  34. *
  35. * $s= new SieveClient('imap.example.com');
  36. * $s->connect();
  37. * $s->authenticate(SIEVE_SASL_PLAIN, 'user', 'password');
  38. * var_export($s->getScripts());
  39. * $s->close();
  40. * </code>
  41. *
  42. * Usage example [uploading a script from a local file]:
  43. * <code>
  44. * uses('peer.sieve.SieveClient', 'io.File', 'io.FileUtil');
  45. *
  46. * $s= new SieveClient('imap.example.com');
  47. * $s->connect();
  48. * $s->authenticate(SIEVE_SASL_PLAIN, 'user', 'password');
  49. * with ($script= new SieveScript('myscript')); {
  50. * $script->setCode(FileUtil::getContents(new File('myscript.txt')));
  51. * $s->putScript($script);
  52. * }
  53. * $s->close();
  54. * </code>
  55. *
  56. * @see rfc://3028 Sieve: A Mail Filtering Language
  57. * @see rfc://3431 Sieve Extension: Relational Tests
  58. * @see rfc://3598 Sieve Email Filtering -- Subaddress Extension
  59. * @see rfc://2298 Extensible Message Format for Message Disposition Notifications (MDNs)
  60. * @see http://www.cyrusoft.com/sieve/drafts/managesieve-04.txt
  61. * @see http://www.cyrusoft.com/sieve/
  62. * @purpose Sieve Implementation
  63. */
  64. class SieveClient extends Object implements Traceable {
  65. public
  66. $cat = NULL;
  67. public
  68. $_sock = NULL,
  69. $_sinfo = array();
  70. /**
  71. * Constructor
  72. *
  73. * @param string host
  74. * @param int port default 2000
  75. */
  76. public function __construct($host, $port= 2000) {
  77. $this->_sock= new Socket($host, $port);
  78. }
  79. /**
  80. * Connect to sieve server
  81. *
  82. * @return bool success
  83. * @throws io.IOException in case connecting failed
  84. * @throws lang.FormatException in case the response cannot be parsed
  85. */
  86. public function connect() {
  87. $this->_sock->connect();
  88. // Read the banner message. Example:
  89. //
  90. // "IMPLEMENTATION" "Cyrus timsieved v1.0.0"
  91. // "SASL" "LOGIN PLAIN KERBEROS_V4 DIGEST-MD5 CRAM-MD5"
  92. // "SIEVE" "fileinto reject envelope vacation imapflags notify subaddress regex"
  93. // OK
  94. do {
  95. if (!($line= $this->_sock->readLine())) return FALSE;
  96. $this->cat && $this->cat->debug('<<<', $line);
  97. if ('OK' == substr($line, 0, 2)) {
  98. break;
  99. } else if ('"' == $line{0}) {
  100. sscanf($line, '"%[^"]" "%[^"]"', $key, $value);
  101. switch ($key) {
  102. case 'IMPLEMENTATION':
  103. $this->_sinfo[$key]= $value;
  104. break;
  105. case 'SASL':
  106. case 'SIEVE':
  107. $this->_sinfo[$key]= explode(' ', strtoupper($value));
  108. break;
  109. case 'STARTTLS':
  110. $this->_sinfo[$key]= TRUE;
  111. break;
  112. default:
  113. throw new FormatException('Cannot parse banner message line >'.$line.'<');
  114. }
  115. continue;
  116. }
  117. throw new FormatException('Unexpected response line >'.$line.'<');
  118. } while (1);
  119. $this->cat && $this->cat->debug('Server information:', $this->_sinfo);
  120. return TRUE;
  121. }
  122. /**
  123. * Wrapper that sends a command to the remote host.
  124. *
  125. * @param string format
  126. * @param var* args
  127. * @return bool success
  128. */
  129. protected function _sendcmd() {
  130. $a= func_get_args();
  131. $cmd= vsprintf(array_shift($a), $a);
  132. $this->cat && $this->cat->debug('>>>', $cmd);
  133. return $this->_sock->write($cmd."\r\n");
  134. }
  135. /**
  136. * Wrapper that reads the response from the remote host, returning
  137. * it into an array if not specified otherwise.
  138. *
  139. * Stops reading at one of the terminals "OK", "NO" or "BYE".
  140. *
  141. * @param bool discard default FALSE
  142. * @param bool error default TRUE
  143. * @return string[]
  144. * @throws lang.FormatException in case "NO" occurs
  145. * @throws peer.SocketException in case "BYE" occurs
  146. */
  147. protected function _response($discard= FALSE, $error= TRUE) {
  148. $lines= array();
  149. do {
  150. $line= $this->_sock->readLine();
  151. $this->cat && $this->cat->debug('<<<', $line);
  152. if ('OK' == substr($line, 0, 2)) {
  153. break;
  154. } else if ('NO' == substr($line, 0, 2)) {
  155. if (!$error) return FALSE;
  156. throw new FormatException(substr($line, 3));
  157. } else if ('BYE' == substr($line, 0, 3)) {
  158. throw new SocketException(substr($line, 4));
  159. } else if (!$discard) {
  160. $lines[]= $line;
  161. }
  162. } while (!$this->_sock->eof());
  163. return $discard ? TRUE : $lines;
  164. }
  165. /**
  166. * Return server implementation
  167. *
  168. * @return string
  169. */
  170. public function getImplementation() {
  171. return $this->_sinfo['IMPLEMENTATION'];
  172. }
  173. /**
  174. * Retrieve supported modules. Return value is an array of modules
  175. * reported by the server consisting of the SIEVE_MOD_* constants.
  176. *
  177. * @return string[]
  178. */
  179. public function getSupportedModules() {
  180. return $this->_sinfo['SIEVE'];
  181. }
  182. /**
  183. * Check whether a specified module is supported
  184. *
  185. * @param string method one of the SIEVE_MOD_* constants
  186. * @return bool
  187. */
  188. public function supportsModule($module) {
  189. return in_array($module, $this->_sinfo['SIEVE']);
  190. }
  191. /**
  192. * Retrieve possible authentication methods. Return value is an
  193. * array of supported methods reported by the server consisting
  194. * of the SIEVE_SASL_* constants.
  195. *
  196. * @return string[]
  197. */
  198. public function getAuthenticationMethods() {
  199. return $this->_sinfo['SASL'];
  200. }
  201. /**
  202. * Checks whether a specied authentication is available.
  203. *
  204. * @param string method one of the SIEVE_SASL_* constants
  205. * @return bool
  206. */
  207. public function hasAuthenticationMethod($method) {
  208. return in_array($method, $this->_sinfo['SASL']);
  209. }
  210. /**
  211. * Authenticate
  212. *
  213. * Supported methods:
  214. * <ul>
  215. * <li>PLAIN</li>
  216. * <li>LOGIN</li>
  217. * <li>DIGEST-MD5</li>
  218. * <li>CRAM-MD5</li>
  219. * </ul>
  220. *
  221. * @param string method one of the SIEVE_SASL_* constants
  222. * @param string user
  223. * @param string pass
  224. * @param string auth default NULL
  225. * @return bool success
  226. * @throws lang.IllegalArgumentException when the specified method is not supported
  227. */
  228. public function authenticate($method, $user, $pass, $auth= NULL) {
  229. if (!$this->hasAuthenticationMethod($method)) {
  230. throw new IllegalArgumentException('Authentication method '.$method.' not supported');
  231. }
  232. // Check whether we want to impersonate
  233. if (NULL === $auth) $auth= $user;
  234. // Send auth request depending on specified authentication method
  235. switch ($method) {
  236. case SIEVE_SASL_PLAIN:
  237. $e= base64_encode($auth."\0".$user."\0".$pass);
  238. $this->_sendcmd('AUTHENTICATE "PLAIN" {%d+}', strlen($e));
  239. $this->_sendcmd($e);
  240. break;
  241. case SIEVE_SASL_LOGIN:
  242. $this->_sendcmd('AUTHENTICATE "LOGIN"');
  243. $ue= base64_encode($user);
  244. $this->_sendcmd('{%d+}', strlen($ue));
  245. $this->_sendcmd($ue);
  246. $pe= base64_encode($pass);
  247. $this->_sendcmd('{%d+}', strlen($pe));
  248. $this->_sendcmd($pe);
  249. break;
  250. case SIEVE_SASL_DIGEST_MD5:
  251. $this->_sendcmd('AUTHENTICATE "DIGEST-MD5"');
  252. // Read server challenge. Example (decoded):
  253. //
  254. // realm="example.com",nonce="GMybUaOM4lpMlJbeRwxOLzTalYDwLAxv/sLf8de4DPA=",
  255. // qop="auth,auth-int,auth-conf",cipher="rc4-40,rc4-56,rc4",charset=utf-8,
  256. // algorithm=md5-sess
  257. //
  258. // See also xp://security.sasl.DigestChallenge
  259. $len= $this->_sock->readLine(0x400);
  260. $str= base64_decode($this->_sock->readLine());
  261. $this->cat && $this->cat->debug('Challenge (length '.$len.'):', $str);
  262. $challenge= DigestChallenge::fromString($str);
  263. $response= $challenge->responseFor(DC_QOP_AUTH, $user, $pass, $auth);
  264. $this->cat && $this->cat->debug($challenge, $response);
  265. // Build and send challenge response
  266. $response->setDigestUri('sieve/'.$this->_sock->host);
  267. $cmd= $response->getString();
  268. $this->cat && $this->cat->debug('Sending challenge response', $cmd);
  269. $this->_sendcmd('"%s"', base64_encode($cmd));
  270. // Finally, read the response auth
  271. $len= $this->_sock->readLine();
  272. $str= base64_decode($this->_sock->readLine());
  273. $this->cat && $this->cat->debug('Response auth (length '.$len.'):', $str);
  274. return TRUE;
  275. case SIEVE_SASL_CRAM_MD5:
  276. $this->_sendcmd('AUTHENTICATE "CRAM-MD5"');
  277. // Read server challenge. Example (decoded):
  278. //
  279. // <2687127488.3645700@example.com>
  280. //
  281. // See also rfc://2195
  282. $len= $this->_sock->readLine(0x400);
  283. $challenge= base64_decode($this->_sock->readLine());
  284. $this->cat && $this->cat->debug('Challenge (length '.$len.'):', $challenge);
  285. // Build response and send it
  286. $response= sprintf(
  287. '%s %s',
  288. $user,
  289. bin2hex(HMAC_MD5::hash($challenge, $pass))
  290. );
  291. $this->cat && $this->cat->debug('Sending challenge response', $response);
  292. $this->_sendcmd('"%s"', base64_encode($response));
  293. break;
  294. default:
  295. throw new IllegalArgumentException('Authentication method '.$method.' not implemented');
  296. }
  297. // Read the response. Examples:
  298. //
  299. // - OK
  300. // - NO ("SASL" "authentication failure") "Authentication error"
  301. return $this->_response(TRUE);
  302. }
  303. /**
  304. * Retrieve a list of scripts stored on the server
  305. *
  306. * @return peer.sieve.SieveScript[] scripts
  307. */
  308. public function getScripts() {
  309. $r= array();
  310. foreach ($this->getScriptNames() as $name => $info) {
  311. with ($s= $this->getScript($name)); {
  312. $s->setActive('ACTIVE' == $info); // Only one at a time
  313. }
  314. $r[]= $s;
  315. }
  316. return $r;
  317. }
  318. /**
  319. * Retrieve a list of scripts names.
  320. *
  321. * @return array
  322. */
  323. public function getScriptNames() {
  324. $this->_sendcmd('LISTSCRIPTS');
  325. // Response is something like this:
  326. //
  327. // "bigmessages"
  328. // "spam" ACTIVE
  329. $r= array();
  330. foreach ($this->_response() as $line) {
  331. if (!sscanf($line, '"%[^"]" %s', $name, $info)) continue;
  332. $r[$name]= $info;
  333. }
  334. return $r;
  335. }
  336. /**
  337. * Retrieve a script by its name
  338. *
  339. * @param string name
  340. * @return peer.sieve.SieveScript script
  341. */
  342. public function getScript($name) {
  343. $this->_sendcmd('GETSCRIPT "%s"', $name);
  344. if (!($r= $this->_response())) return $r;
  345. // Response is something like this:
  346. //
  347. // {59}
  348. // if size :over 100K { # this is a comment
  349. // discard;
  350. // }
  351. //
  352. // The number on the first line indicates the length. We simply
  353. // discard this information.
  354. $s= new SieveScript($name);
  355. $s->setCode(implode("\n", array_slice($r, 1)));
  356. return $s;
  357. }
  358. /**
  359. * Delete a script from the server
  360. *
  361. * @param string name
  362. * @return bool success
  363. */
  364. public function deleteScript($name) {
  365. $this->_sendcmd('DELETESCRIPT "%s"', $name);
  366. return $this->_response(TRUE);
  367. }
  368. /**
  369. * Upload a script to the server
  370. *
  371. * @param peer.sieve.SieveScript script
  372. * @return bool success
  373. */
  374. public function putScript($script) {
  375. $this->_sendcmd('PUTSCRIPT "%s" {%d+}', $script->getName(), $script->getLength());
  376. $this->_sendcmd($script->getCode());
  377. return $this->_response(TRUE);
  378. }
  379. /**
  380. * Set a specific script as the active one on the server
  381. *
  382. * A user may have multiple Sieve scripts on the server, yet only one
  383. * script may be used for filtering of incoming messages. This is the
  384. * active script. Users may have zero or one active scripts and MUST
  385. * use the SETACTIVE command described below for changing the active
  386. * script or disabling Sieve processing. For example, a user may have
  387. * an everyday script they normally use and a special script they use
  388. * when they go on vacation. Users can change which script is being
  389. * used without having to download and upload a script stored somewhere
  390. * else.
  391. *
  392. * If the script name is the empty string (i.e. "") then any active
  393. * script is disabled.
  394. *
  395. * @param string name
  396. * @return bool success
  397. */
  398. public function activateScript($name) {
  399. $this->_sendcmd('SETACTIVE "%s"', $name);
  400. return $this->_response(TRUE);
  401. }
  402. /**
  403. * Check whether there is enough space for a script to be uploaded
  404. *
  405. * @param peer.sieve.SieveScript script
  406. * @return bool success
  407. */
  408. public function hasSpaceFor($script) {
  409. $this->_sendcmd('HAVESPACE "%s" %d', $script->getName(), $script->getLength());
  410. return $this->_response(TRUE, FALSE);
  411. }
  412. /**
  413. * Close connection
  414. *
  415. */
  416. public function close() {
  417. $this->_sock->write("LOGOUT\r\n");
  418. $this->_sock->close();
  419. return TRUE;
  420. }
  421. /**
  422. * Set a trace for debugging
  423. *
  424. * @param util.log.LogCategory cat
  425. */
  426. public function setTrace($cat) {
  427. $this->cat= $cat;
  428. }
  429. }
  430. ?>