From: avl Date: Sun, 7 Sep 2008 21:04:50 +0000 (+0300) Subject: Mailer improve X-Git-Url: https://git.andy128k.dev/?a=commitdiff_plain;h=a5f5761357785639db1b2a4d67a6fdbed54937bc;p=ipf-mail.git Mailer improve --- diff --git a/ipf/mail.php b/ipf/mail.php new file mode 100644 index 0000000..fe0a0f2 --- /dev/null +++ b/ipf/mail.php @@ -0,0 +1,343 @@ +_charset = $charset; + } + + public function getCharset() + { + return $this->_charset; + } + + public function setType($type) + { + $allowed = array( + IPF_Mime::MULTIPART_ALTERNATIVE, + IPF_Mime::MULTIPART_MIXED, + IPF_Mime::MULTIPART_RELATED, + ); + if (!in_array($type, $allowed)) { + throw new IPF_Exception_Mail('Invalid content type "' . $type . '"'); + } + + $this->_type = $type; + return $this; + } + + public function getType() + { + return $this->_type; + } + + public function setMimeBoundary($boundary) + { + $this->_mimeBoundary = $boundary; + + return $this; + } + + public function getMimeBoundary() + { + return $this->_mimeBoundary; + } + + public function setBodyText($txt, $charset = null, $encoding = IPF_Mime::ENCODING_QUOTEDPRINTABLE) + { + if ($charset === null) { + $charset = $this->_charset; + } + + $mp = new IPF_Mime_Part($txt); + $mp->encoding = $encoding; + $mp->type = IPF_Mime::TYPE_TEXT; + $mp->disposition = IPF_Mime::DISPOSITION_INLINE; + $mp->charset = $charset; + + $this->_bodyText = $mp; + + return $this; + } + + public function getBodyText($textOnly = false) + { + if ($textOnly && $this->_bodyText) { + $body = $this->_bodyText; + return $body->getContent(); + } + + return $this->_bodyText; + } + + public function setBodyHtml($html, $charset = null, $encoding = IPF_Mime::ENCODING_QUOTEDPRINTABLE) + { + if ($charset === null) { + $charset = $this->_charset; + } + + $mp = new IPF_Mime_Part($html); + $mp->encoding = $encoding; + $mp->type = IPF_Mime::TYPE_HTML; + $mp->disposition = IPF_Mime::DISPOSITION_INLINE; + $mp->charset = $charset; + + $this->_bodyHtml = $mp; + + return $this; + } + + public function getBodyHtml($htmlOnly = false) + { + if ($htmlOnly && $this->_bodyHtml) { + $body = $this->_bodyHtml; + return $body->getContent(); + } + + return $this->_bodyHtml; + } + + public function addAttachment(IPF_Mime_Part $attachment) + { + $this->addPart($attachment); + $this->hasAttachments = true; + + return $this; + } + + public function createAttachment($body, + $mimeType = IPF_Mime::TYPE_OCTETSTREAM, + $disposition = IPF_Mime::DISPOSITION_ATTACHMENT, + $encoding = IPF_Mime::ENCODING_BASE64, + $filename = null) + { + + $mp = new IPF_Mime_Part($body); + $mp->encoding = $encoding; + $mp->type = $mimeType; + $mp->disposition = $disposition; + $mp->filename = $filename; + + $this->addAttachment($mp); + + return $mp; + } + + public function getPartCount() + { + return count($this->_parts); + } + + protected function _encodeHeader($value) + { + if (IPF_Mime::isPrintable($value)) { + return $value; + } else { + $quotedValue = IPF_Mime::encodeQuotedPrintable($value); + $quotedValue = str_replace(array('?', ' '), array('=3F', '=20'), $quotedValue); + return '=?' . $this->_charset . '?Q?' . $quotedValue . '?='; + } + } + + protected function _storeHeader($headerName, $value, $append=false) + { +// ?? $value = strtr($value,"\r\n\t",'???'); + if (isset($this->_headers[$headerName])) { + $this->_headers[$headerName][] = $value; + } else { + $this->_headers[$headerName] = array($value); + } + + if ($append) { + $this->_headers[$headerName]['append'] = true; + } + + } + + protected function _addRecipient($email, $to = false) + { + // prevent duplicates + $this->_recipients[$email] = 1; + + if ($to) { + $this->_to[] = $email; + } + } + + protected function _addRecipientAndHeader($headerName, $name, $email) + { + $email = strtr($email,"\r\n\t",'???'); + $this->_addRecipient($email, ('To' == $headerName) ? true : false); + if ($name != '') { + $name = '"' . $this->_encodeHeader($name) . '" '; + } + + $this->_storeHeader($headerName, $name .'<'. $email . '>', true); + } + + public function addTo($email, $name='') + { + $this->_addRecipientAndHeader('To', $name, $email); + return $this; + } + + public function addCc($email, $name='') + { + $this->_addRecipientAndHeader('Cc', $name, $email); + return $this; + } + + public function addBcc($email) + { + $this->_addRecipientAndHeader('Bcc', '', $email); + return $this; + } + + public function getRecipients() + { + return array_keys($this->_recipients); + } + + public function setFrom($email, $name = '') + { + if ($this->_from === null) { + $email = strtr($email,"\r\n\t",'???'); + $this->_from = $email; + $this->_storeHeader('From', $this->_encodeHeader('"'.$name.'"').' <'.$email.'>', true); + } else { + throw new IPF_Exception_Mail('From Header set twice'); + } + return $this; + } + + public function getFrom() + { + return $this->_from; + } + + public function setReturnPath($email) + { + if ($this->_returnPath === null) { + $email = strtr($email,"\r\n\t",'???'); + $this->_returnPath = $email; + $this->_storeHeader('Return-Path', $email, false); + } else { + throw new IPF_Exception_Mail('Return-Path Header set twice'); + } + return $this; + } + + public function getReturnPath() + { + if (null !== $this->_returnPath) { + return $this->_returnPath; + } + + return $this->_from; + } + + public function setSubject($subject) + { + if ($this->_subject === null) { + $subject = strtr($subject,"\r\n\t",'???'); + $this->_subject = $this->_encodeHeader($subject); + $this->_storeHeader('Subject', $this->_subject); + } else { + throw new IPF_Exception_Mail('Subject set twice'); + } + return $this; + } + + public function getSubject() + { + return $this->_subject; + } + + public function setDate($date = null) + { + if ($this->_date === null) { + if ($date === null) { + $date = date('r'); + } else if (is_int($date)) { + $date = date('r', $date); + } else if (is_string($date)) { + $date = strtotime($date); + if ($date === false || $date < 0) { + throw new IPF_Exception_Mail('String representations of Date Header must be ' . + 'strtotime()-compatible'); + } + $date = date('r', $date); + } else { + throw new IPF_Exception_Mail(__METHOD__ . ' only accepts UNIX timestamps and strtotime()-compatible strings'); + } + $this->_date = $date; + $this->_storeHeader('Date', $date); + } else { + throw new IPF_Exception_Mail('Date Header set twice'); + } + return $this; + } + + public function getDate() + { + return $this->_date; + } + + public function addHeader($name, $value, $append = false) + { + if (in_array(strtolower($name), array('to', 'cc', 'bcc', 'from', 'subject', 'return-path', 'date'))) { + throw new IPF_Exception_Mail('Cannot set standard header from addHeader()'); + } + + $value = strtr($value,"\r\n\t",'???'); + $value = $this->_encodeHeader($value); + $this->_storeHeader($name, $value, $append); + + return $this; + } + + public function getHeaders() + { + return $this->_headers; + } + + public function send($transport = null) + { + if ($transport === null) { + if (! self::$_defaultTransport instanceof IPF_Mail_Transport_Abstract) { + $transport = new IPF_Mail_Transport_Sendmail(); + } else { + $transport = self::$_defaultTransport; + } + } + + if (is_null($this->_date)) { + $this->setDate(); + } + + $transport->send($this); + + return $this; + } + +} diff --git a/ipf/mail/message.php b/ipf/mail/message.php new file mode 100644 index 0000000..8260fdd --- /dev/null +++ b/ipf/mail/message.php @@ -0,0 +1,42 @@ +_flags = array_combine($params['flags'], $params['flags']); + } + + parent::__construct($params); + } + + public function getTopLines() + { + return $this->_topLines; + } + + public function hasFlag($flag) + { + return isset($this->_flags[$flag]); + } + + public function getFlags() + { + return $this->_flags; + } +} diff --git a/ipf/mail/part.php b/ipf/mail/part.php new file mode 100644 index 0000000..d5d54c1 --- /dev/null +++ b/ipf/mail/part.php @@ -0,0 +1,238 @@ +_mail = $params['handler']; + $this->_messageNum = $params['id']; + } + + if (isset($params['raw'])) { + IPF_Mime_Decode::splitMessage($params['raw'], $this->_headers, $this->_content); + } else if (isset($params['headers'])) { + if (is_array($params['headers'])) { + $this->_headers = $params['headers']; + } else { + if (!empty($params['noToplines'])) { + IPF_Mime_Decode::splitMessage($params['headers'], $this->_headers, $null); + } else { + IPF_Mime_Decode::splitMessage($params['headers'], $this->_headers, $this->_topLines); + } + } + if (isset($params['content'])) { + $this->_content = $params['content']; + } + } + } + + public function isMultipart() + { + try { + return stripos($this->contentType, 'multipart/') === 0; + } catch(IPF_Exception_Mail $e) { + return false; + } + } + + + public function getContent() + { + if ($this->_content !== null) { + return $this->_content; + } + + if ($this->_mail) { + return $this->_mail->getRawContent($this->_messageNum); + } else { + throw new IPF_Exception_Mail('no content'); + } + } + + protected function _cacheContent() + { + // caching content if we can't fetch parts + if ($this->_content === null && $this->_mail) { + $this->_content = $this->_mail->getRawContent($this->_messageNum); + } + + if (!$this->isMultipart()) { + return; + } + + // split content in parts + $boundary = $this->getHeaderField('content-type', 'boundary'); + if (!$boundary) { + throw new IPF_Exception_Mail('no boundary found in content type to split message'); + } + $parts = IPF_Mime_Decode::splitMessageStruct($this->_content, $boundary); + $counter = 1; + foreach ($parts as $part) { + $this->_parts[$counter++] = new self(array('headers' => $part['header'], 'content' => $part['body'])); + } + } + + public function getPart($num) + { + if (isset($this->_parts[$num])) { + return $this->_parts[$num]; + } + + if (!$this->_mail && $this->_content === null) { + throw new IPF_Exception_Mail('part not found'); + } + + if ($this->_mail && $this->_mail->hasFetchPart) { + // TODO: fetch part + // return + } + + $this->_cacheContent(); + + if (!isset($this->_parts[$num])) { + throw new IPF_Exception_Mail('part not found'); + } + + return $this->_parts[$num]; + } + + public function countParts() + { + if ($this->_countParts) { + return $this->_countParts; + } + + $this->_countParts = count($this->_parts); + if ($this->_countParts) { + return $this->_countParts; + } + + if ($this->_mail && $this->_mail->hasFetchPart) { + // TODO: fetch part + // return + } + + $this->_cacheContent(); + + $this->_countParts = count($this->_parts); + return $this->_countParts; + } + + public function getHeaders() + { + if ($this->_headers === null) { + if (!$this->_mail) { + $this->_headers = array(); + } else { + $part = $this->_mail->getRawHeader($this->_messageNum); + IPF_Mime_Decode::splitMessage($part, $this->_headers, $null); + } + } + + return $this->_headers; + } + + public function getHeader($name, $format = null) + { + if ($this->_headers === null) { + $this->getHeaders(); + } + + $lowerName = strtolower($name); + + if (!isset($this->_headers[$lowerName])) { + $lowerName = strtolower(preg_replace('%([a-z])([A-Z])%', '\1-\2', $name)); + if (!isset($this->_headers[$lowerName])) { + throw new IPF_Exception_Mail("no Header with Name $name found"); + } + } + $name = $lowerName; + + $header = $this->_headers[$name]; + + switch ($format) { + case 'string': + if (is_array($header)) { + $header = implode(IPF_Mime::LINEEND, $header); + } + break; + case 'array': + $header = (array)$header; + default: + // do nothing + } + + return $header; + } + + public function getHeaderField($name, $wantedPart = 0, $firstName = 0) { + return IPF_Mime_Decode::splitHeaderField(current($this->getHeader($name, 'array')), $wantedPart, $firstName); + } + + public function __get($name) + { + return $this->getHeader($name, 'string'); + } + + public function __toString() + { + return $this->getContent(); + } + + public function hasChildren() + { + $current = $this->current(); + return $current && $current instanceof IPF_Mail_Part && $current->isMultipart(); + } + + public function getChildren() + { + return $this->current(); + } + + public function valid() + { + if ($this->_countParts === null) { + $this->countParts(); + } + return $this->_iterationPos && $this->_iterationPos <= $this->_countParts; + } + + public function next() + { + ++$this->_iterationPos; + } + + public function key() + { + return $this->_iterationPos; + } + + public function current() + { + return $this->getPart($this->_iterationPos); + } + + public function rewind() + { + $this->countParts(); + $this->_iterationPos = 1; + } +} diff --git a/ipf/mail/protocol/abstract.php b/ipf/mail/protocol/abstract.php new file mode 100644 index 0000000..139884c --- /dev/null +++ b/ipf/mail/protocol/abstract.php @@ -0,0 +1,147 @@ +_host = $host; + $this->_port = $port; + } + + public function __destruct() + { + $this->_disconnect(); + } + + abstract public function connect(); + public function getRequest() + { + return $this->_request; + } + + public function getResponse() + { + return $this->_response; + } + + public function getLog() + { + return $this->_log; + } + + public function resetLog() + { + $this->_log = ''; + } + + protected function _connect($remote) + { + $errorNum = 0; + $errorStr = ''; + + // open connection + $this->_socket = stream_socket_client($remote, $errorNum, $errorStr, self::TIMEOUT_CONNECTION); + + if ($this->_socket === false) { + if ($errorNum == 0) { + $errorStr = 'Could not open socket'; + } + throw new IPF_Exception_Mail($errorStr); + } + + if (($result = stream_set_timeout($this->_socket, self::TIMEOUT_CONNECTION)) === false) { + throw new IPF_Exception_Mail('Could not set stream timeout'); + } + + return $result; + } + + protected function _disconnect() + { + if (is_resource($this->_socket)) { + fclose($this->_socket); + } + } + + protected function _send($request) + { + if (!is_resource($this->_socket)) { + throw new IPF_Exception_Mail('No connection has been established to ' . $this->_host); + } + + $this->_request = $request; + + $result = fwrite($this->_socket, $request . self::EOL); + + // Save request to internal log + $this->_log .= $request . self::EOL; + + if ($result === false) { + throw new IPF_Exception_Mail('Could not send request to ' . $this->_host); + } + + return $result; + } + + protected function _receive($timeout = null) + { + if (!is_resource($this->_socket)) { + throw new IPF_Exception_Mail('No connection has been established to ' . $this->_host); + } + + // Adapters may wish to supply per-commend timeouts according to appropriate RFC + if ($timeout !== null) { + stream_set_timeout($this->_socket, $timeout); + } + + // Retrieve response + $reponse = fgets($this->_socket, 1024); + + // Save request to internal log + $this->_log .= $reponse; + + // Check meta data to ensure connection is still valid + $info = stream_get_meta_data($this->_socket); + + if (!empty($info['timed_out'])) { + throw new IPF_Exception_Mail($this->_host . ' has timed out'); + } + + if ($reponse === false) { + throw new IPF_Exception_Mail('Could not read from ' . $this->_host); + } + + return $reponse; + } + + protected function _expect($code, $timeout = null) + { + $this->_response = array(); + $cmd = ''; + $msg = ''; + if (!is_array($code)) { + $code = array($code); + } + do { + $this->_response[] = $result = $this->_receive($timeout); + sscanf($result, $this->_template, $cmd, $msg); + + if ($cmd === null || !in_array($cmd, $code)) { + throw new IPF_Exception_Mail($result); + } + + } while (strpos($msg, '-') === 0); // The '-' message prefix indicates an information string instead of a response string. + return $msg; + } +} diff --git a/ipf/mail/protocol/imap.php b/ipf/mail/protocol/imap.php new file mode 100644 index 0000000..109e488 --- /dev/null +++ b/ipf/mail/protocol/imap.php @@ -0,0 +1,505 @@ +connect($host, $port, $ssl); + } + } + + public function __destruct() + { + $this->logout(); + } + + public function connect($host, $port = null, $ssl = false) + { + if ($ssl == 'SSL') { + $host = 'ssl://' . $host; + } + + if ($port === null) { + $port = $ssl === 'SSL' ? 993 : 143; + } + + $this->_socket = @fsockopen($host, $port); + if (!$this->_socket) { + throw new IPF_Exception_Mail('cannot connect to host'); + } + + if (!$this->_assumedNextLine('* OK')) { + throw new IPF_Exception_Mail('host doesn\'t allow connection'); + } + + if ($ssl === 'TLS') { + $result = $this->requestAndResponse('STARTTLS'); + $result = $result && stream_socket_enable_crypto($this->_socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + if (!$result) { + throw new IPF_Exception_Mail('cannot enable TLS'); + } + } + } + + protected function _nextLine() + { + $line = @fgets($this->_socket); + if ($line === false) { + throw new IPF_Exception_Mail('cannot read - connection closed?'); + } + + return $line; + } + + protected function _assumedNextLine($start) + { + $line = $this->_nextLine(); + return strpos($line, $start) === 0; + } + + protected function _nextTaggedLine(&$tag) + { + $line = $this->_nextLine(); + + // seperate tag from line + list($tag, $line) = explode(' ', $line, 2); + + return $line; + } + + protected function _decodeLine($line) + { + $tokens = array(); + $stack = array(); + + /* + We start to decode the response here. The unterstood tokens are: + literal + "literal" or also "lit\\er\"al" + {bytes}literal + (literals*) + All tokens are returned in an array. Literals in braces (the last unterstood + token in the list) are returned as an array of tokens. I.e. the following response: + "foo" baz {3}bar ("f\\\"oo" bar) + would be returned as: + array('foo', 'baz', 'bar', array('f\\\"oo', 'bar')); + + // TODO: add handling of '[' and ']' to parser for easier handling of response text + */ + // replace any trailling including spaces with a single space + $line = rtrim($line) . ' '; + while (($pos = strpos($line, ' ')) !== false) { + $token = substr($line, 0, $pos); + while ($token[0] == '(') { + array_push($stack, $tokens); + $tokens = array(); + $token = substr($token, 1); + } + if ($token[0] == '"') { + if (preg_match('%^"((.|\\\\|\\")*?)" *%', $line, $matches)) { + $tokens[] = $matches[1]; + $line = substr($line, strlen($matches[0])); + continue; + } + } + if ($token[0] == '{') { + $endPos = strpos($token, '}'); + $chars = substr($token, 1, $endPos - 1); + if (is_numeric($chars)) { + $token = ''; + while (strlen($token) < $chars) { + $token .= $this->_nextLine(); + } + $line = ''; + if (strlen($token) > $chars) { + $line = substr($token, $chars); + $token = substr($token, 0, $chars); + } else { + $line .= $this->_nextLine(); + } + $tokens[] = $token; + $line = trim($line) . ' '; + continue; + } + } + if ($stack && $token[strlen($token) - 1] == ')') { + // closing braces are not seperated by spaces, so we need to count them + $braces = strlen($token); + $token = rtrim($token, ')'); + // only count braces if more than one + $braces -= strlen($token) + 1; + // only add if token had more than just closing braces + if ($token) { + $tokens[] = $token; + } + $token = $tokens; + $tokens = array_pop($stack); + // special handline if more than one closing brace + while ($braces-- > 0) { + $tokens[] = $token; + $token = $tokens; + $tokens = array_pop($stack); + } + } + $tokens[] = $token; + $line = substr($line, $pos + 1); + } + + // maybe the server forgot to send some closing braces + while ($stack) { + $child = $tokens; + $tokens = array_pop($stack); + $tokens[] = $child; + } + + return $tokens; + } + + public function readLine(&$tokens = array(), $wantedTag = '*', $dontParse = false) + { + $line = $this->_nextTaggedLine($tag); + if (!$dontParse) { + $tokens = $this->_decodeLine($line); + } else { + $tokens = $line; + } + + // if tag is wanted tag we might be at the end of a multiline response + return $tag == $wantedTag; + } + + public function readResponse($tag, $dontParse = false) + { + $lines = array(); + while (!$this->readLine($tokens, $tag, $dontParse)) { + $lines[] = $tokens; + } + + if ($dontParse) { + // last to chars are still needed for response code + $tokens = array(substr($tokens, 0, 2)); + } + // last line has response code + if ($tokens[0] == 'OK') { + return $lines ? $lines : true; + } else if ($tokens[0] == 'NO'){ + return false; + } + return null; + } + + public function sendRequest($command, $tokens = array(), &$tag = null) + { + if (!$tag) { + ++$this->_tagCount; + $tag = 'TAG' . $this->_tagCount; + } + + $line = $tag . ' ' . $command; + + foreach ($tokens as $token) { + if (is_array($token)) { + if (@fputs($this->_socket, $line . ' ' . $token[0] . "\r\n") === false) { + throw new IPF_Exception_Mail('cannot write - connection closed?'); + } + if (!$this->_assumedNextLine('+ ')) { + throw new IPF_Exception_Mail('cannot send literal string'); + } + $line = $token[1]; + } else { + $line .= ' ' . $token; + } + } + + if (@fputs($this->_socket, $line . "\r\n") === false) { + throw new IPF_Exception_Mail('cannot write - connection closed?'); + } + } + + public function requestAndResponse($command, $tokens = array(), $dontParse = false) + { + $this->sendRequest($command, $tokens, $tag); + $response = $this->readResponse($tag, $dontParse); + + return $response; + } + + public function escapeString($string) + { + if (func_num_args() < 2) { + if (strpos($string, "\n") !== false) { + return array('{' . strlen($string) . '}', $string); + } else { + return '"' . str_replace(array('\\', '"'), array('\\\\', '\\"'), $string) . '"'; + } + } + $result = array(); + foreach (func_get_args() as $string) { + $result[] = $this->escapeString($string); + } + return $result; + } + + public function escapeList($list) + { + $result = array(); + foreach ($list as $k => $v) { + if (!is_array($v)) { +// $result[] = $this->escapeString($v); + $result[] = $v; + continue; + } + $result[] = $this->escapeList($v); + } + return '(' . implode(' ', $result) . ')'; + } + + public function login($user, $password) + { + return $this->requestAndResponse('LOGIN', $this->escapeString($user, $password), true); + } + + public function logout() + { + $result = false; + if ($this->_socket) { + try { + $result = $this->requestAndResponse('LOGOUT', array(), true); + } catch (IPF_Exception_Mail $e) { + // ignoring exception + } + fclose($this->_socket); + $this->_socket = null; + } + return $result; + } + + public function capability() + { + $response = $this->requestAndResponse('CAPABILITY'); + + if (!$response) { + return $response; + } + + $capabilities = array(); + foreach ($response as $line) { + $capabilities = array_merge($capabilities, $line); + } + return $capabilities; + } + + public function examineOrSelect($command = 'EXAMINE', $box = 'INBOX') + { + $this->sendRequest($command, array($this->escapeString($box)), $tag); + + $result = array(); + while (!$this->readLine($tokens, $tag)) { + if ($tokens[0] == 'FLAGS') { + array_shift($tokens); + $result['flags'] = $tokens; + continue; + } + switch ($tokens[1]) { + case 'EXISTS': + case 'RECENT': + $result[strtolower($tokens[1])] = $tokens[0]; + break; + case '[UIDVALIDITY': + $result['uidvalidity'] = (int)$tokens[2]; + break; + default: + // ignore + } + } + + if ($tokens[0] != 'OK') { + return false; + } + return $result; + } + + public function select($box = 'INBOX') + { + return $this->examineOrSelect('SELECT', $box); + } + + public function examine($box = 'INBOX') + { + return $this->examineOrSelect('EXAMINE', $box); + } + + public function fetch($items, $from, $to = null) + { + if (is_array($from)) { + $set = implode(',', $from); + } else if ($to === null) { + $set = (int)$from; + } else if ($to === INF) { + $set = (int)$from . ':*'; + } else { + $set = (int)$from . ':' . (int)$to; + } + + $items = (array)$items; + $itemList = $this->escapeList($items); + + $this->sendRequest('FETCH', array($set, $itemList), $tag); + + $result = array(); + while (!$this->readLine($tokens, $tag)) { + // ignore other responses + if ($tokens[1] != 'FETCH') { + continue; + } + // ignore other messages + if ($to === null && !is_array($from) && $tokens[0] != $from) { + continue; + } + // if we only want one item we return that one directly + if (count($items) == 1) { + if ($tokens[2][0] == $items[0]) { + $data = $tokens[2][1]; + } else { + // maybe the server send an other field we didn't wanted + $count = count($tokens[2]); + // we start with 2, because 0 was already checked + for ($i = 2; $i < $count; $i += 2) { + if ($tokens[2][$i] != $items[0]) { + continue; + } + $data = $tokens[2][$i + 1]; + break; + } + } + } else { + $data = array(); + while (key($tokens[2]) !== null) { + $data[current($tokens[2])] = next($tokens[2]); + next($tokens[2]); + } + } + // if we want only one message we can ignore everything else and just return + if ($to === null && !is_array($from) && $tokens[0] == $from) { + // we still need to read all lines + while (!$this->readLine($tokens, $tag)); + return $data; + } + $result[$tokens[0]] = $data; + } + + if ($to === null && !is_array($from)) { + throw new IPF_Exception_Mail('the single id was not found in response'); + } + + return $result; + } + + public function listMailbox($reference = '', $mailbox = '*') + { + $result = array(); + $list = $this->requestAndResponse('LIST', $this->escapeString($reference, $mailbox)); + if (!$list || $list === true) { + return $result; + } + + foreach ($list as $item) { + if (count($item) != 4 || $item[0] != 'LIST') { + continue; + } + $result[$item[3]] = array('delim' => $item[2], 'flags' => $item[1]); + } + + return $result; + } + + public function store(array $flags, $from, $to = null, $mode = null, $silent = true) + { + $item = 'FLAGS'; + if ($mode == '+' || $mode == '-') { + $item = $mode . $item; + } + if ($silent) { + $item .= '.SILENT'; + } + + $flags = $this->escapeList($flags); + $set = (int)$from; + if ($to != null) { + $set .= ':' . ($to == INF ? '*' : (int)$to); + } + + $result = $this->requestAndResponse('STORE', array($set, $item, $flags), $silent); + + if ($silent) { + return $result ? true : false; + } + + $tokens = $result; + $result = array(); + foreach ($tokens as $token) { + if ($token[1] != 'FETCH' || $token[2][0] != 'FLAGS') { + continue; + } + $result[$token[0]] = $token[2][1]; + } + + return $result; + } + + public function append($folder, $message, $flags = null, $date = null) + { + $tokens = array(); + $tokens[] = $this->escapeString($folder); + if ($flags !== null) { + $tokens[] = $this->escapeList($flags); + } + if ($date !== null) { + $tokens[] = $this->escapeString($date); + } + $tokens[] = $this->escapeString($message); + + return $this->requestAndResponse('APPEND', $tokens, true); + } + + public function copy($folder, $from, $to = null) + { + $set = (int)$from; + if ($to != null) { + $set .= ':' . ($to == INF ? '*' : (int)$to); + } + + return $this->requestAndResponse('COPY', array($set, $this->escapeString($folder)), true); + } + + public function create($folder) + { + return $this->requestAndResponse('CREATE', array($this->escapeString($folder)), true); + } + + public function rename($old, $new) + { + return $this->requestAndResponse('RENAME', $this->escapeString($old, $new), true); + } + + public function delete($folder) + { + return $this->requestAndResponse('DELETE', array($this->escapeString($folder)), true); + } + + public function expunge() + { + // TODO: parse response? + return $this->requestAndResponse('EXPUNGE'); + } + + public function noop() + { + // TODO: parse response + return $this->requestAndResponse('NOOP'); + } +} diff --git a/ipf/mail/protocol/pop3.php b/ipf/mail/protocol/pop3.php new file mode 100644 index 0000000..4648b48 --- /dev/null +++ b/ipf/mail/protocol/pop3.php @@ -0,0 +1,246 @@ +connect($host, $port, $ssl); + } + } + + public function __destruct() + { + $this->logout(); + } + + public function connect($host, $port = null, $ssl = false) + { + if ($ssl == 'SSL') { + $host = 'ssl://' . $host; + } + + if ($port === null) { + $port = $ssl == 'SSL' ? 995 : 110; + } + + $this->_socket = @fsockopen($host, $port); + if (!$this->_socket) { + throw new IPF_Exception_Mail('cannot connect to host'); + } + + $welcome = $this->readResponse(); + + strtok($welcome, '<'); + $this->_timestamp = strtok('>'); + if (!strpos($this->_timestamp, '@')) { + $this->_timestamp = null; + } else { + $this->_timestamp = '<' . $this->_timestamp . '>'; + } + + if ($ssl === 'TLS') { + $this->request('STLS'); + $result = stream_socket_enable_crypto($this->_socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + if (!$result) { + throw new IPF_Exception_Mail('cannot enable TLS'); + } + } + + return $welcome; + } + + public function sendRequest($request) + { + $result = @fputs($this->_socket, $request . "\r\n"); + if (!$result) { + throw new IPF_Exception_Mail('send failed - connection closed?'); + } + } + + public function readResponse($multiline = false) + { + $result = @fgets($this->_socket); + if (!is_string($result)) { + throw new IPF_Exception_Mail('read failed - connection closed?'); + } + + $result = trim($result); + if (strpos($result, ' ')) { + list($status, $message) = explode(' ', $result, 2); + } else { + $status = $result; + $message = ''; + } + + if ($status != '+OK') { + throw new IPF_Exception_Mail('last request failed'); + } + + if ($multiline) { + $message = ''; + $line = fgets($this->_socket); + while ($line && trim($line) != '.') { + $message .= $line; + $line = fgets($this->_socket); + }; + } + + return $message; + } + + public function request($request, $multiline = false) + { + $this->sendRequest($request); + return $this->readResponse($multiline); + } + + public function logout() + { + if (!$this->_socket) { + return; + } + + try { + $this->request('QUIT'); + } catch (IPF_Exception_Mail $e) { + // ignore error - we're closing the socket anyway + } + + fclose($this->_socket); + $this->_socket = null; + } + + public function capa() + { + $result = $this->request('CAPA', true); + return explode("\n", $result); + } + + public function login($user, $password, $tryApop = true) + { + if ($tryApop && $this->_timestamp) { + try { + $this->request("APOP $user " . md5($this->_timestamp . $password)); + return; + } catch (IPF_Exception_Mail $e) { + // ignore + } + } + + $result = $this->request("USER $user"); + $result = $this->request("PASS $password"); + } + + public function status(&$messages, &$octets) + { + $messages = 0; + $octets = 0; + $result = $this->request('STAT'); + + list($messages, $octets) = explode(' ', $result); + } + + public function getList($msgno = null) + { + if ($msgno !== null) { + $result = $this->request("LIST $msgno"); + + list(, $result) = explode(' ', $result); + return (int)$result; + } + + $result = $this->request('LIST', true); + $messages = array(); + $line = strtok($result, "\n"); + while ($line) { + list($no, $size) = explode(' ', trim($line)); + $messages[(int)$no] = (int)$size; + $line = strtok("\n"); + } + + return $messages; + } + + public function uniqueid($msgno = null) + { + if ($msgno !== null) { + $result = $this->request("UIDL $msgno"); + + list(, $result) = explode(' ', $result); + return $result; + } + + $result = $this->request('UIDL', true); + + $result = explode("\n", $result); + $messages = array(); + foreach ($result as $line) { + if (!$line) { + continue; + } + list($no, $id) = explode(' ', trim($line), 2); + $messages[(int)$no] = $id; + } + + return $messages; + + } + + public function top($msgno, $lines = 0, $fallback = false) + { + if ($this->hasTop === false) { + if ($fallback) { + return $this->retrieve($msgno); + } else { + throw new IPF_Exception_Mail('top not supported and no fallback wanted'); + } + } + $this->hasTop = true; + + $lines = (!$lines || $lines < 1) ? 0 : (int)$lines; + + try { + $result = $this->request("TOP $msgno $lines", true); + } catch (IPF_Exception_Mail $e) { + $this->hasTop = false; + if ($fallback) { + $result = $this->retrieve($msgno); + } else { + throw $e; + } + } + + return $result; + } + + public function retrive($msgno) + { + return $this->retrieve($msgno); + } + + public function retrieve($msgno) + { + $result = $this->request("RETR $msgno", true); + return $result; + } + + public function noop() + { + $this->request('NOOP'); + } + + public function delete($msgno) + { + $this->request("DELE $msgno"); + } + + public function undelete() + { + $this->request('RSET'); + } +} diff --git a/ipf/mail/protocol/smtp.php b/ipf/mail/protocol/smtp.php new file mode 100644 index 0000000..c122bf7 --- /dev/null +++ b/ipf/mail/protocol/smtp.php @@ -0,0 +1,199 @@ +_secure = 'tls'; + break; + + case 'ssl': + $this->_transport = 'ssl'; + $this->_secure = 'ssl'; + if ($port == null) { + $port = 465; + } + break; + + default: + throw new IPF_Exception_Mail($config['ssl'] . ' is unsupported SSL type'); + break; + } + } + + // If no port has been specified then check the master PHP ini file. Defaults to 25 if the ini setting is null. + if ($port == null) { + if (($port = ini_get('smtp_port')) == '') { + $port = 25; + } + } + + parent::__construct($host, $port); + } + + + public function connect() + { + return $this->_connect($this->_transport . '://' . $this->_host . ':'. $this->_port); + } + + public function helo($host = '127.0.0.1') + { + // Respect RFC 2821 and disallow HELO attempts if session is already initiated. + if ($this->_sess === true) { + throw new IPF_Exception_Mail('Cannot issue HELO to existing session'); + } + + // Validate client hostname + if (!$this->_validHost->isValid($host)) { + throw new IPF_Exception_Mail(join(', ', $this->_validHost->getMessage())); + } + + // Initiate helo sequence + $this->_expect(220, 300); // Timeout set for 5 minutes as per RFC 2821 4.5.3.2 + $this->_ehlo($host); + + // If a TLS session is required, commence negotiation + if ($this->_secure == 'tls') { + $this->_send('STARTTLS'); + $this->_expect(220, 180); + if (!stream_socket_enable_crypto($this->_socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new IPF_Exception_Mail('Unable to connect via TLS'); + } + $this->_ehlo($host); + } + + $this->_startSession(); + $this->auth(); + } + + protected function _ehlo($host) + { + // Support for older, less-compliant remote servers. Tries multiple attempts of EHLO or HELO. + try { + $this->_send('EHLO ' . $host); + $this->_expect(250, 300); // Timeout set for 5 minutes as per RFC 2821 4.5.3.2 + } catch (IPF_Exception_Mail $e) { + $this->_send('HELO ' . $host); + $this->_expect(250, 300); // Timeout set for 5 minutes as per RFC 2821 4.5.3.2 + } catch (IPF_Exception_Mail $e) { + throw $e; + } + } + + public function mail($from) + { + if ($this->_sess !== true) { + throw new IPF_Exception_Mail('A valid session has not been started'); + } + + $this->_send('MAIL FROM:<' . $from . '>'); + $this->_expect(250, 300); // Timeout set for 5 minutes as per RFC 2821 4.5.3.2 + + // Set mail to true, clear recipients and any existing data flags as per 4.1.1.2 of RFC 2821 + $this->_mail = true; + $this->_rcpt = false; + $this->_data = false; + } + + public function rcpt($to) + { + if ($this->_mail !== true) { + throw new IPF_Exception_Mail('No sender reverse path has been supplied'); + } + + // Set rcpt to true, as per 4.1.1.3 of RFC 2821 + $this->_send('RCPT TO:<' . $to . '>'); + $this->_expect(array(250, 251), 300); // Timeout set for 5 minutes as per RFC 2821 4.5.3.2 + $this->_rcpt = true; + } + + public function data($data) + { + // Ensure recipients have been set + if ($this->_rcpt !== true) { + throw new IPF_Exception_Mail('No recipient forward path has been supplied'); + } + + $this->_send('DATA'); + $this->_expect(354, 120); // Timeout set for 2 minutes as per RFC 2821 4.5.3.2 + + foreach (explode(IPF_Mime::LINEEND, $data) as $line) { + if (strpos($line, '.') === 0) { + // Escape lines prefixed with a '.' + $line = '.' . $line; + } + $this->_send($line); + } + + $this->_send('.'); + $this->_expect(250, 600); // Timeout set for 10 minutes as per RFC 2821 4.5.3.2 + $this->_data = true; + } + + public function rset() + { + $this->_send('RSET'); + // MS ESMTP doesn't follow RFC, see [ZF-1377] + $this->_expect(array(250, 220)); + + $this->_mail = false; + $this->_rcpt = false; + $this->_data = false; + } + + public function noop() + { + $this->_send('NOOP'); + $this->_expect(250, 300); // Timeout set for 5 minutes as per RFC 2821 4.5.3.2 + } + + public function vrfy($user) + { + $this->_send('VRFY ' . $user); + $this->_expect(array(250, 251, 252), 300); // Timeout set for 5 minutes as per RFC 2821 4.5.3.2 + } + + public function quit() + { + if ($this->_sess) { + $this->_send('QUIT'); + $this->_expect(221, 300); // Timeout set for 5 minutes as per RFC 2821 4.5.3.2 + $this->_stopSession(); + } + } + + public function auth() + { + if ($this->_auth === true) { + throw new IPF_Exception_Mail('Already authenticated for this session'); + } + } + + public function disconnect() + { + $this->_disconnect(); + } + + protected function _startSession() + { + $this->_sess = true; + } + + protected function _stopSession() + { + $this->_sess = false; + } +} diff --git a/ipf/mail/protocol/smtp/auth/crammd5.php b/ipf/mail/protocol/smtp/auth/crammd5.php new file mode 100644 index 0000000..576033e --- /dev/null +++ b/ipf/mail/protocol/smtp/auth/crammd5.php @@ -0,0 +1,49 @@ +_username = $config['username']; + } + if (isset($config['password'])) { + $this->_password = $config['password']; + } + } + + parent::__construct($host, $port, $config); + } + + public function auth() + { + // Ensure AUTH has not already been initiated. + parent::auth(); + + $this->_send('AUTH CRAM-MD5'); + $challenge = $this->_expect(334); + $challenge = base64_decode($challenge); + $digest = $this->_hmacMd5($this->_password, $challenge); + $this->_send(base64_encode($this->_username . ' ' . $digest)); + $this->_expect(235); + $this->_auth = true; + } + + protected function _hmacMd5($key, $data, $block = 64) + { + if (strlen($key) > 64) { + $key = pack('H32', md5($key)); + } elseif (strlen($key) < 64) { + $key = str_pad($key, $block, chr(0)); + } + + $k_ipad = substr($key, 0, 64) ^ str_repeat(chr(0x36), 64); + $k_opad = substr($key, 0, 64) ^ str_repeat(chr(0x5C), 64); + + $inner = pack('H32', md5($k_ipad . $data)); + $digest = md5($k_opad . $inner); + + return $digest; + } +} diff --git a/ipf/mail/protocol/smtp/auth/login.php b/ipf/mail/protocol/smtp/auth/login.php new file mode 100644 index 0000000..4d24b9f --- /dev/null +++ b/ipf/mail/protocol/smtp/auth/login.php @@ -0,0 +1,35 @@ +_username = $config['username']; + } + if (isset($config['password'])) { + $this->_password = $config['password']; + } + } + + parent::__construct($host, $port, $config); + } + + public function auth() + { + // Ensure AUTH has not already been initiated. + parent::auth(); + + $this->_send('AUTH LOGIN'); + $this->_expect(334); + $this->_send(base64_encode($this->_username)); + $this->_expect(334); + $this->_send(base64_encode($this->_password)); + $this->_expect(235); + $this->_auth = true; + } +} diff --git a/ipf/mail/protocol/smtp/auth/plain.php b/ipf/mail/protocol/smtp/auth/plain.php new file mode 100644 index 0000000..38ed235 --- /dev/null +++ b/ipf/mail/protocol/smtp/auth/plain.php @@ -0,0 +1,33 @@ +_username = $config['username']; + } + if (isset($config['password'])) { + $this->_password = $config['password']; + } + } + + parent::__construct($host, $port, $config); + } + + public function auth() + { + // Ensure AUTH has not already been initiated. + parent::auth(); + + $this->_send('AUTH PLAIN'); + $this->_expect(334); + $this->_send(base64_encode(chr(0) . $this->_username . chr(0) . $this->_password)); + $this->_expect(235); + $this->_auth = true; + } +} diff --git a/ipf/mail/transport/abstract.php b/ipf/mail/transport/abstract.php new file mode 100644 index 0000000..68e5044 --- /dev/null +++ b/ipf/mail/transport/abstract.php @@ -0,0 +1,183 @@ +_mail->getType(); + if (!$type) { + if ($this->_mail->hasAttachments) { + $type = IPF_Mime::MULTIPART_MIXED; + } elseif ($this->_mail->getBodyText() && $this->_mail->getBodyHtml()) { + $type = IPF_Mime::MULTIPART_ALTERNATIVE; + } else { + $type = IPF_Mime::MULTIPART_MIXED; + } + } + + $this->_headers['Content-Type'] = array( + $type . '; charset="' . $this->_mail->getCharset() . '";' + . $this->EOL + . " " . 'boundary="' . $boundary . '"' + ); + $this->_headers['MIME-Version'] = array('1.0'); + + $this->boundary = $boundary; + } + + return $this->_headers; + } + + protected static function _formatHeader(&$item, $key, $prefix) + { + $item = $prefix . ': ' . $item; + } + + protected function _prepareHeaders($headers) + { + if (!$this->_mail) { + throw new IPF_Exception_Mail('Missing IPF_Mail object in _mail property'); + } + + $this->header = ''; + + foreach ($headers as $header => $content) { + if (isset($content['append'])) { + unset($content['append']); + $value = implode(',' . $this->EOL . ' ', $content); + $this->header .= $header . ': ' . $value . $this->EOL; + } else { + array_walk($content, array(get_class($this), '_formatHeader'), $header); + $this->header .= implode($this->EOL, $content) . $this->EOL; + } + } + + // Sanity check on headers -- should not be > 998 characters + $sane = true; + foreach (explode($this->EOL, $this->header) as $line) { + if (strlen(trim($line)) > 998) { + $sane = false; + break; + } + } + if (!$sane) { + throw new IPF_Exception_Mail('At least one mail header line is too long'); + } + } + + protected function _buildBody() + { + if (($text = $this->_mail->getBodyText()) + && ($html = $this->_mail->getBodyHtml())) + { + // Generate unique boundary for multipart/alternative + $mime = new IPF_Mime(null); + $boundaryLine = $mime->boundaryLine($this->EOL); + $boundaryEnd = $mime->mimeEnd($this->EOL); + + $text->disposition = false; + $html->disposition = false; + + $body = $boundaryLine + . $text->getHeaders($this->EOL) + . $this->EOL + . $text->getContent($this->EOL) + . $this->EOL + . $boundaryLine + . $html->getHeaders($this->EOL) + . $this->EOL + . $html->getContent($this->EOL) + . $this->EOL + . $boundaryEnd; + + $mp = new IPF_Mime_Part($body); + $mp->type = IPF_Mime::MULTIPART_ALTERNATIVE; + $mp->boundary = $mime->boundary(); + + $this->_isMultipart = true; + + // Ensure first part contains text alternatives + array_unshift($this->_parts, $mp); + + // Get headers + $this->_headers = $this->_mail->getHeaders(); + return; + } + + // If not multipart, then get the body + if (false !== ($body = $this->_mail->getBodyHtml())) { + array_unshift($this->_parts, $body); + } elseif (false !== ($body = $this->_mail->getBodyText())) { + array_unshift($this->_parts, $body); + } + + if (!$body) { + throw new IPF_Exception_Mail('No body specified'); + } + + // Get headers + $this->_headers = $this->_mail->getHeaders(); + $headers = $body->getHeadersArray($this->EOL); + foreach ($headers as $header) { + // Headers in IPF_Mime_Part are kept as arrays with two elements, a + // key and a value + $this->_headers[$header[0]] = array($header[1]); + } + } + + public function send(IPF_Mail $mail) + { + $this->_isMultipart = false; + $this->_mail = $mail; + $this->_parts = $mail->getParts(); + $mime = $mail->getMime(); + + // Build body content + $this->_buildBody(); + + // Determine number of parts and boundary + $count = count($this->_parts); + $boundary = null; + if ($count < 1) { + throw new IPF_Exception_Mail('Empty mail cannot be sent'); + } + + if ($count > 1) { + // Multipart message; create new MIME object and boundary + $mime = new IPF_Mime($this->_mail->getMimeBoundary()); + $boundary = $mime->boundary(); + } elseif ($this->_isMultipart) { + // multipart/alternative -- grab boundary + $boundary = $this->_parts[0]->boundary; + } + + // Determine recipients, and prepare headers + $this->recipients = implode(',', $mail->getRecipients()); + $this->_prepareHeaders($this->_getHeaders($boundary)); + + // Create message body + // This is done so that the same IPF_Mail object can be used in + // multiple transports + $message = new IPF_Mime_Message(); + $message->setParts($this->_parts); + $message->setMime($mime); + $this->body = $message->generateMessage($this->EOL); + + // Send to transport! + $this->_sendMail(); + } +} diff --git a/ipf/mail/transport/sendmail.php b/ipf/mail/transport/sendmail.php new file mode 100644 index 0000000..487b654 --- /dev/null +++ b/ipf/mail/transport/sendmail.php @@ -0,0 +1,71 @@ +parameters = $parameters; + } + + public function _sendMail() + { + if ($this->parameters === null) { + $result = mail( + $this->recipients, + $this->_mail->getSubject(), + $this->body, + $this->header); + } else { + $result = mail( + $this->recipients, + $this->_mail->getSubject(), + $this->body, + $this->header, + $this->parameters); + } + if (!$result) { + throw new IPF_Exception_Mail('Unable to send mail'); + } + } + + protected function _prepareHeaders($headers) + { + if (!$this->_mail) { + throw new IPF_Exception_Mail('_prepareHeaders requires a registered IPF_Mail object'); + } + + // mail() uses its $to parameter to set the To: header, and the $subject + // parameter to set the Subject: header. We need to strip them out. + if (0 === strpos(PHP_OS, 'WIN')) { + // If the current recipients list is empty, throw an error + if (empty($this->recipients)) { + throw new IPF_Exception_Mail('Missing To addresses'); + } + } else { + // All others, simply grab the recipients and unset the To: header + if (!isset($headers['To'])) { + throw new IPF_Exception_Mail('Missing To header'); + } + + unset($headers['To']['append']); + $this->recipients = implode(',', $headers['To']); + } + + // Remove recipient header + unset($headers['To']); + + // Remove subject header, if present + if (isset($headers['Subject'])) { + unset($headers['Subject']); + } + + // Prepare headers + parent::_prepareHeaders($headers); + } + +} + diff --git a/ipf/mail/transport/smtp.php b/ipf/mail/transport/smtp.php new file mode 100644 index 0000000..d37ded4 --- /dev/null +++ b/ipf/mail/transport/smtp.php @@ -0,0 +1,91 @@ +_name = $config['name']; + } + if (isset($config['port'])) { + $this->_port = $config['port']; + } + if (isset($config['auth'])) { + $this->_auth = $config['auth']; + } + + $this->_host = $host; + $this->_config = $config; + } + + public function __destruct() + { + if ($this->_connection instanceof IPF_Mail_Protocol_Smtp) { + try { + $this->_connection->quit(); + } catch (IPF_Exception_Mail $e) { + // ignore + } + $this->_connection->disconnect(); + } + } + + public function setConnection(IPF_Mail_Protocol_Abstract $connection) + { + $this->_connection = $connection; + } + + public function getConnection() + { + return $this->_connection; + } + + public function _sendMail() + { + // If sending multiple messages per session use existing adapter + if (!($this->_connection instanceof IPF_Mail_Protocol_Smtp)) { + // Check if authentication is required and determine required class + $connectionClass = 'IPF_Mail_Protocol_Smtp'; + if ($this->_auth) { + $connectionClass .= '_Auth_' . ucwords($this->_auth); + } + $this->setConnection(new $connectionClass($this->_host, $this->_port, $this->_config)); + $this->_connection->connect(); + $this->_connection->helo($this->_name); + } else { + // Reset connection to ensure reliable transaction + $this->_connection->rset(); + } + + // Set mail return path from sender email address + $this->_connection->mail($this->_mail->getReturnPath()); + + // Set recipient forward paths + foreach ($this->_mail->getRecipients() as $recipient) { + $this->_connection->rcpt($recipient); + } + + // Issue DATA command to client + $this->_connection->data($this->header . IPF_Mime::LINEEND . $this->body); + } + + protected function _prepareHeaders($headers) + { + if (!$this->_mail) { + throw new IPF_Exception_Mail('_prepareHeaders requires a registered IPF_Mail object'); + } + + unset($headers['Bcc']); + + // Prepare headers + parent::_prepareHeaders($headers); + } +} diff --git a/ipf/mime.php b/ipf/mime.php new file mode 100644 index 0000000..c3252b3 --- /dev/null +++ b/ipf/mime.php @@ -0,0 +1,165 @@ + $lineLength) { + $ptr = $lineLength; + } + + // Ensure we are not splitting across an encoded character + $pos = strrpos(substr($str, 0, $ptr), '='); + if ($pos !== false && $pos >= $ptr - 2) { + $ptr = $pos; + } + + // Check if there is a space at the end of the line and rewind + if ($ptr > 0 && $str[$ptr - 1] == ' ') { + --$ptr; + } + + // Add string and continue + $out .= substr($str, 0, $ptr) . '=' . $lineEnd; + $str = substr($str, $ptr); + } + + $out = rtrim($out, $lineEnd); + $out = rtrim($out, '='); + return $out; + } + + public static function encodeBase64($str, + $lineLength = self::LINELENGTH, + $lineEnd = self::LINEEND) + { + return rtrim(chunk_split(base64_encode($str), $lineLength, $lineEnd)); + } + + public function __construct($boundary = null) + { + // This string needs to be somewhat unique + if ($boundary === null) { + $this->_boundary = '=_' . md5(microtime(1) . self::$makeUnique++); + } else { + $this->_boundary = $boundary; + } + } + + public static function encode($str, $encoding, $EOL = self::LINEEND) + { + switch ($encoding) { + case self::ENCODING_BASE64: + return self::encodeBase64($str, self::LINELENGTH, $EOL); + + case self::ENCODING_QUOTEDPRINTABLE: + return self::encodeQuotedPrintable($str, self::LINELENGTH, $EOL); + + default: + /** + * @todo 7Bit and 8Bit is currently handled the same way. + */ + return $str; + } + } + + public function boundary() + { + return $this->_boundary; + } + + public function boundaryLine($EOL = self::LINEEND) + { + return $EOL . '--' . $this->_boundary . $EOL; + } + + public function mimeEnd($EOL = self::LINEEND) + { + return $EOL . '--' . $this->_boundary . '--' . $EOL; + } +} diff --git a/ipf/mime/decode.php b/ipf/mime/decode.php new file mode 100644 index 0000000..a1e92de --- /dev/null +++ b/ipf/mime/decode.php @@ -0,0 +1,153 @@ + $headers, + 'body' => $body ); + } + return $result; + } + + public static function splitMessage($message, &$headers, &$body, $EOL = IPF_Mime::LINEEND) + { + // check for valid header at first line + $firstline = strtok($message, "\n"); + if (!preg_match('%^[^\s]+[^:]*:%', $firstline)) { + $headers = array(); + // TODO: we're ignoring \r for now - is this function fast enough and is it safe to asume noone needs \r? + $body = str_replace(array("\r", "\n"), array('', $EOL), $message); + return; + } + + // find an empty line between headers and body + // default is set new line + if (strpos($message, $EOL . $EOL)) { + list($headers, $body) = explode($EOL . $EOL, $message, 2); + // next is the standard new line + } else if ($EOL != "\r\n" && strpos($message, "\r\n\r\n")) { + list($headers, $body) = explode("\r\n\r\n", $message, 2); + // next is the other "standard" new line + } else if ($EOL != "\n" && strpos($message, "\n\n")) { + list($headers, $body) = explode("\n\n", $message, 2); + // at last resort find anything that looks like a new line + } else { + @list($headers, $body) = @preg_split("%([\r\n]+)\\1%U", $message, 2); + } + + $headers = iconv_mime_decode_headers($headers, ICONV_MIME_DECODE_CONTINUE_ON_ERROR); + + // normalize header names + foreach ($headers as $name => $header) { + $lower = strtolower($name); + if ($lower == $name) { + continue; + } + unset($headers[$name]); + if (!isset($headers[$lower])) { + $headers[$lower] = $header; + continue; + } + if (is_array($headers[$lower])) { + $headers[$lower][] = $header; + continue; + } + $headers[$lower] = array($headers[$lower], $header); + } + } + + public static function splitContentType($type, $wantedPart = null) + { + return self::splitHeaderField($type, $wantedPart, 'type'); + } + + public static function splitHeaderField($field, $wantedPart = null, $firstName = 0) + { + $wantedPart = strtolower($wantedPart); + $firstName = strtolower($firstName); + + // special case - a bit optimized + if ($firstName === $wantedPart) { + $field = strtok($field, ';'); + return $field[0] == '"' ? substr($field, 1, -1) : $field; + } + + $field = $firstName . '=' . $field; + if (!preg_match_all('%([^=\s]+)\s*=("[^"]+"|[^;]+)(;\s*|$)%', $field, $matches)) { + throw new IPF_Exception('not a valid header field'); + } + + if ($wantedPart) { + foreach ($matches[1] as $key => $name) { + if (strcasecmp($name, $wantedPart)) { + continue; + } + if ($matches[2][$key][0] != '"') { + return $matches[2][$key]; + } + return substr($matches[2][$key], 1, -1); + } + return null; + } + + $split = array(); + foreach ($matches[1] as $key => $name) { + $name = strtolower($name); + if ($matches[2][$key][0] == '"') { + $split[$name] = substr($matches[2][$key], 1, -1); + } else { + $split[$name] = $matches[2][$key]; + } + } + + return $split; + } + + public static function decodeQuotedPrintable($string) + { + return iconv_mime_decode($string, ICONV_MIME_DECODE_CONTINUE_ON_ERROR); + } +} diff --git a/ipf/mime/message.php b/ipf/mime/message.php new file mode 100644 index 0000000..e351e08 --- /dev/null +++ b/ipf/mime/message.php @@ -0,0 +1,155 @@ +_parts; + } + + public function setParts($parts) + { + $this->_parts = $parts; + } + + public function addPart(IPF_Mime_Part $part) + { + /** + * @todo check for duplicate object handle + */ + $this->_parts[] = $part; + } + + public function isMultiPart() + { + return (count($this->_parts) > 1); + } + + public function setMime(IPF_Mime $mime) + { + $this->_mime = $mime; + } + + public function getMime() + { + if ($this->_mime === null) { + $this->_mime = new IPF_Mime(); + } + + return $this->_mime; + } + + public function generateMessage($EOL = IPF_Mime::LINEEND) + { + if (! $this->isMultiPart()) { + $body = array_shift($this->_parts); + $body = $body->getContent($EOL); + } else { + $mime = $this->getMime(); + + $boundaryLine = $mime->boundaryLine($EOL); + $body = 'This is a message in Mime Format. If you see this, ' + . "your mail reader does not support this format." . $EOL; + + foreach (array_keys($this->_parts) as $p) { + $body .= $boundaryLine + . $this->getPartHeaders($p, $EOL) + . $EOL + . $this->getPartContent($p, $EOL); + } + + $body .= $mime->mimeEnd($EOL); + } + + return trim($body); + } + + public function getPartHeadersArray($partnum) + { + return $this->_parts[$partnum]->getHeadersArray(); + } + + public function getPartHeaders($partnum, $EOL = IPF_Mime::LINEEND) + { + return $this->_parts[$partnum]->getHeaders($EOL); + } + + public function getPartContent($partnum, $EOL = IPF_Mime::LINEEND) + { + return $this->_parts[$partnum]->getContent($EOL); + } + + protected static function _disassembleMime($body, $boundary) + { + $start = 0; + $res = array(); + // find every mime part limiter and cut out the + // string before it. + // the part before the first boundary string is discarded: + $p = strpos($body, '--'.$boundary."\n", $start); + if ($p === false) { + // no parts found! + return array(); + } + + // position after first boundary line + $start = $p + 3 + strlen($boundary); + + while (($p = strpos($body, '--' . $boundary . "\n", $start)) !== false) { + $res[] = substr($body, $start, $p-$start); + $start = $p + 3 + strlen($boundary); + } + + // no more parts, find end boundary + $p = strpos($body, '--' . $boundary . '--', $start); + if ($p===false) { + throw new IPF_Exception('Not a valid Mime Message: End Missing'); + } + + // the remaining part also needs to be parsed: + $res[] = substr($body, $start, $p-$start); + return $res; + } + + public static function createFromMessage($message, $boundary, $EOL = IPF_Mime::LINEEND) + { + $parts = IPF_Mime_Decode::splitMessageStruct($message, $boundary, $EOL); + + $res = new self(); + foreach ($parts as $part) { + // now we build a new MimePart for the current Message Part: + $newPart = new IPF_Mime_Part($part); + foreach ($part['header'] as $key => $value) { + /** + * @todo check for characterset and filename + */ + // list($key, $value) = $header; + switch($key) { + case 'content-type': + $newPart->type = $value; + break; + case 'content-transfer-encoding': + $newPart->encoding = $value; + break; + case 'content-id': + $newPart->id = trim($value,'<>'); + break; + case 'Content-Disposition': + $newPart->disposition = $value; + break; + case 'content-description': + $newPart->description = $value; + break; + default: + throw new IPF_Exception('Unknown header ignored for MimePart:' . $key); + } + } + $res->addPart($newPart); + } + return $res; + } +} diff --git a/ipf/mime/part.php b/ipf/mime/part.php new file mode 100644 index 0000000..87d638b --- /dev/null +++ b/ipf/mime/part.php @@ -0,0 +1,128 @@ +_content = $content; + if (is_resource($content)) { + $this->_isStream = true; + } + } + + public function isStream() + { + return $this->_isStream; + } + + public function getEncodedStream() + { + if (!$this->_isStream) { + throw new IPF_Exception('Attempt to get a stream from a string part'); + } + + //stream_filter_remove(); // ??? is that right? + switch ($this->encoding) { + case IPF_Mime::ENCODING_QUOTEDPRINTABLE: + $filter = stream_filter_append( + $this->_content, + 'convert.quoted-printable-encode', + STREAM_FILTER_READ, + array( + 'line-length' => 76, + 'line-break-chars' => IPF_Mime::LINEEND + ) + ); + if (!is_resource($filter)) { + throw new IPF_Exception('Failed to append quoted-printable filter'); + } + break; + case IPF_Mime::ENCODING_BASE64: + $filter = stream_filter_append( + $this->_content, + 'convert.base64-encode', + STREAM_FILTER_READ, + array( + 'line-length' => 76, + 'line-break-chars' => IPF_Mime::LINEEND + ) + ); + if (!is_resource($filter)) { + throw new IPF_Exception('Failed to append base64 filter'); + } + break; + default: + } + return $this->_content; + } + + public function getContent($EOL = IPF_Mime::LINEEND) + { + if ($this->_isStream) { + return stream_get_contents($this->getEncodedStream()); + } else { + return IPF_Mime::encode($this->_content, $this->encoding, $EOL); + } + } + + public function getHeadersArray($EOL = IPF_Mime::LINEEND) + { + $headers = array(); + + $contentType = $this->type; + if ($this->charset) { + $contentType .= '; charset="' . $this->charset . '"'; + } + + if ($this->boundary) { + $contentType .= ';' . $EOL + . " boundary=\"" . $this->boundary . '"'; + } + + $headers[] = array('Content-Type', $contentType); + + if ($this->encoding) { + $headers[] = array('Content-Transfer-Encoding', $this->encoding); + } + + if ($this->id) { + $headers[] = array('Content-ID', '<' . $this->id . '>'); + } + + if ($this->disposition) { + $disposition = $this->disposition; + if ($this->filename) { + $disposition .= '; filename="' . $this->filename . '"'; + } + $headers[] = array('Content-Disposition', $disposition); + } + + if ($this->description) { + $headers[] = array('Content-Description', $this->description); + } + + return $headers; + } + + public function getHeaders($EOL = IPF_Mime::LINEEND) + { + $res = ''; + foreach ($this->getHeadersArray($EOL) as $header) { + $res .= $header[0] . ': ' . $header[1] . $EOL; + } + + return $res; + } +}