Copyright (c) 2006-2013 osTicket http://www.osticket.com Mod: Travis Mitchell http://inflectionnetwork.com Released under the GNU General Public License WITHOUT ANY WARRANTY. See LICENSE.TXT for details. vim: expandtab sw=4 ts=4 sts=4: **********************************************************************/ require_once(INCLUDE_DIR.'class.mailparse.php'); require_once(INCLUDE_DIR.'class.ticket.php'); require_once(INCLUDE_DIR.'class.dept.php'); require_once(INCLUDE_DIR.'class.email.php'); require_once(INCLUDE_DIR.'class.filter.php'); require_once(INCLUDE_DIR.'class.format.php'); require_once(INCLUDE_DIR.'html2text.php'); require_once(ZEND_DIR.'autoload.php'); class MailFetcher { var $ht; var $zmail; var $zerror; var $charset = 'UTF-8'; function MailFetcher($email, $charset='UTF-8') { if($email && is_numeric($email)) //email_id $email = Email::lookup($email); if(is_object($email)) $this->ht = $email->getMailAccountInfo(); elseif(is_array($email) && $email['host']) //hashtable of mail account info $this->ht = $email; else $this->ht = null; $this->charset = $charset; if($this->ht) { if(!strcasecmp($this->ht['protocol'],'pop')) //force pop3 $this->ht['protocol'] = 'pop3'; else $this->ht['protocol'] = strtolower($this->ht['protocol']); //Max fetch per poll if(!$this->ht['max_fetch'] || !is_numeric($this->ht['max_fetch'])) $this->ht['max_fetch'] = 20; } } //end function MailFetcher function getEmailId() { return $this->ht['email_id']; } //end function getEmailId function getHost() { return $this->ht['host']; } //end function getHost function getPort() { return $this->ht['port']; } //end function getPort function getProtocol() { if(!strcasecmp($this->ht['protocol'],"imap")) $this->ht['protocol'] = "Imap"; else $this->ht['protocol'] = "Pop3"; return $this->ht['protocol']; } //end function getProtocol function getEncryption() { return $this->ht['ssl']; } //end function getEncryption function getUsername() { return $this->ht['user']; } //end function getUsername function getPassword() { return $this->ht['password']; } //end function getPassword function canDeleteEmails() { return ($this->ht['delete_mail']); } //end function canDeleteEmails function getMaxFetch() { return $this->ht['max_fetch']; } //end function getMaxFetch function getArchiveFolder() { return $this->ht['archive_folder']; } //end function getArchiveFolder function open() { if ($this->zmail) $this->close(); try { $zmail_protocol = new Zend\Mail\Protocol\Imap($this->getHost(),$this->getPort(),$this->getEncryption()); $this->zmail = $zmail_protocol->connect($this->getHost(),$this->getPort(),$this->getEncryption()); $this->zmail = $zmail_protocol->login($this->getUsername(),$this->getPassword()); } catch (\Exception $ex) { $this->getLastError($ex); } return $this->zmail; } //end function open function close() { if($this->zmail->logout()) $this->zmail = False; return $this->zmail; } //end function close function mailcount() { return $this->zmail->countMessages(); } //end function mailcount function getMailboxes() { if(!is_object($this->zmail)) { $zmail_config = array('host' => $this->getHost(), 'port' => $this->getPort(), 'ssl' => $this->getEncryption(), 'user' => $this->getUsername(), 'password' => $this->getPassword()); try { $zmail = new Zend\Mail\Storage\Imap($zmail_config); } catch (\Exception $ex) { $this->getLastError($ex); } } $list = $this->zmail->getFolders(); return $list; } //end function getMailboxes function createMailbox($archiveFolder) { if(!$archiveFolder) return false; return $this->zmail->createFolder($archiveFolder); } //end function createMailbox function checkMailbox($archiveFolder, $create=false) { if(!is_object($this->zmail)) { $zmail_config = array('host' => $this->getHost(), 'port' => $this->getPort(), 'ssl' => $this->getEncryption(), 'user' => $this->getUsername(), 'password' => $this->getPassword()); try { $this->zmail = new Zend\Mail\Storage\Imap($zmail_config); } catch (\Exception $ex) { $this->getLastError($ex); } } try { $this->zmail->selectFolder($archiveFolder); } catch (\Exception $ex) { $this->getLastError($ex); $this->createMailbox($archiveFolder); } return $create; } //end function checkMailbox function decode($text, $encoding) { switch($encoding) { case 'utf-8': //Convert an 8bit string to a quoted-printable string $text = quoted_printable_encode($text); break; case 'binary': //Convert an 8bit string to a base64 string $text = base64_encode($text); break; case 'base64': //Decode BASE64 encoded text $text = base64_decode($text); break; case 'quoted-printable': //Convert a quoted-printable string to an 8 bit string $text = quoted_printable_decode($text); break; } //end switch-case for encoding $text with the proper $encoding type return $text; } //end function decode //Convert text to desired encoding..defaults to utf8 function mime_encode($text, $charset=null, $encoding='utf-8') { return Format::encode($text, $charset, $encoding); } //end function mime_encode function mailbox_encode($mailbox) { if (!$mailbox) return null; // Properly encode the mailbox to UTF-7, according to rfc2060, // section 5.1.3 elseif (function_exists('mb_convert_encoding')) return mb_convert_encoding($mailbox, 'UTF7-IMAP', 'utf-8'); else { try { $msg_encode = new Zend\Mail\Message($mailbox); } catch (\Exception $ex) { $this->getLastError($ex); } return $msg_encode->setEncoding('UTF-7'); } } //end function mailbox_encode /** * Mime header value decoder. Usually unicode characters are encoded * according to RFC-2047. This function will decode RFC-2047 encoded * header values as well as detect headers which are not encoded. * * Caveats: * If headers contain non-ascii characters the result of this function * is completely undefined. If osTicket is corrupting your email * headers, your mail software is not encoding the header text * correctly. * * Returns: * Header value, transocded to UTF-8 */ function mime_decode($text, $charset=null, $encoding='utf-8') { // Handle poorly or completely un-encoded header values ( if (function_exists('mb_detect_encoding')) if (($src_enc = mb_detect_encoding($text)) && (strcasecmp($src_enc, 'ASCII') !== 0)) return Format::encode($text, $src_enc, $encoding); // Handle ASCII text and RFC-2047 encoding $str = ''; $parts = mb_decode_mimeheader($text); $str.= $this->mime_encode($parts, $charset, $encoding); if( $str ) { return $str; } else { return iconv_mime_decode($text, 0, 'UTF-8'); } } //end function mime_decode function getLastError($ex) { $zerror = $ex; return $zerror; } //end function getLastError function getMimeType($part) { $maintype = strtok((strtok($part->contenttype, ';')), '/'); //breaks MIME Type into two parts, main and sub $subtype = strtok('/'); $mimetype = array('TEXT', 'MULTIPART', 'MESSAGE', 'APPLICATION', 'AUDIO', 'IMAGE', 'VIDEO', 'OTHER'); if(!$part || !$subtype) { return 'TEXT/PLAIN'; } //end if either $part or $subtype is null use text/plain return $maintype.'/'.$subtype; } //end function getMimeType function getHeaderInfo($mid) { try { $mailmsg = $this->zmail->getMessage($mid); if(!($headerinfo=$mailmsg->getHeaders()) || !isset($mailmsg->from)) return null; isset($mailmsg->from)?$header['name'] = $mailmsg->from:$header['name'] = ''; isset($mailmsg->returnpath)?$header['email']=$mailmsg->returnpath:$header['email']=''; isset($mailmsg->subject)?$header['subject']=$mailmsg->subject:$header['subject']=''; isset($mailmsg->messageid)?$header['mid']=trim(@$mailmsg->messageid):$header['mid']=''; $header['header']=$this->zmail->getRawHeader($mid); isset($mailmsg->inreplyto)?$header['in-reply-to']=$mailmsg->inreplyto:$header['in-reply-to']=''; isset($mailmsg->references)?$header['references']=$mailmsg->references:$header['references']=''; isset($mailmsg->from)?$header['reply-to-name']=$mailmsg->from:$header['reply-to-name']=''; isset($mailmsg->returnpath)?$header['reply-to']=$mailmsg->returnpath:$header['reply-to']=''; //Try to determine target email - useful when fetched inbox has // aliases that are independent emails within osTicket. $emailId = 0; $tolist = array(); if(isset($headerinfo->to)) $tolist = array_merge($tolist, $headerinfo->to); if(isset($headerinfo->cc)) $tolist = array_merge($tolist, $headerinfo->cc); if(isset($headerinfo->bcc)) $tolist = array_merge($tolist, $headerinfo->bcc); foreach($tolist as $addr) if(($emailId=Email::getIdByEmail(strtolower($addr->mailbox).'@'.$addr->host))) break; $header['emailId'] = $emailId; // Ensure we have a message-id. If unable to read it out of the // email, use the hash of the entire email headers if (!$header['mid'] && $header['header']) if (!($header['mid'] = Mail_Parse::findHeaderEntry($header['header'], 'message-id'))) $header['mid'] = '<' . md5($header['header']) . '@local>'; } catch (\Exception $ex) { $this->getLastError($ex); } return $header; } //end function getHeaderInfo //search for specific mime type parts....encoding is the desired encoding. function getPart($mid, $mimeType, $encoding=false, $struct=null, $partNumber=false) { try { $mailmsg = $this->zmail->getMessage($mid); $text = null; if (!$part && $mid) { if ($mailmsg->isMultipart()) { $partNumber = null; //(re)sets $partNumber to null foreach (new RecursiveIteratorIterator($this->zmail->getMessage($mid)) as $part) { if(strcasecmp($mimeType, $this->getMimeType($part))==0 //the part returned the predefined $mimeType && (!isset($part->contentdisposition) //AND no contentdisposition is set || !$part->getHeaderField('Content-Disposition', 'filename'))) { //OR there is no attached filename $partNumber = $partNumber?$partNumber:1; //if there's a part number use it, otherwise use 1 if ($text = $mailmsg->getPart($partNumber)->getContent()) { $encoding = null; //(re)set encoding to null $charset=null; //(re)set $charset as null if (isset($part->contenttransferencoding)) { //does the encoding field exist $encoding = $part->contenttransferencoding; //get the encoding type if ($encoding //if $encoding is NOT null && ($encoding == 'base64' || $encoding == 'quoted-printable')) { //AND if $encoding is either base64 or qp $text = $this->decode($text, $encoding); //decode the content accordingly $charset = $part->getHeaderField('Content-Type', 'charset'); //determine the $charset to be used $text = $this->mime_encode($text, $charset, $encoding); //mime encode the string $text (the decoded content of $partnumber) using the $charset and $encoding method found in the message $part } } //end if encoding field exists } //end if the content of the message can be retrieved (by $partNumber) of the specified message (by $mid) } //end if the MIME Type is returned and there is no attachment $partNumber++; //increase $partNumber by 1 return $text; } // end foreach going through each mail part } // end if multipart message else { if(strcasecmp($mimeType, $this->getMimeType($mailmsg))==0 //the part returned the predefined $mimeType && (!isset($mailmsg->contentdisposition) //AND no contentdisposition is set || !$mailmsg->getHeaderField('Content-Disposition', 'filename'))) { //OR there is no attached filename if ($text = $mailmsg->getContent()) { $encoding = null; //(re)set encoding to null $charset=null; //(re)set $charset as null if (isset($mailmsg->contenttransferencoding)) { //does the encoding field exist $encoding = $mailmsg->contenttransferencoding; //get the encoding type if ($encoding //if $encoding is NOT null && ($encoding == 'base64' || $encoding == 'quoted-printable')) { //AND if $encoding is either base64 or qp $text = $this->decode($text, $encoding); //decode the content accordingly $charset = $mailmsg->getHeaderField('Content-Type', 'charset'); //determine the $charset to be used $text = $this->mime_encode($text, $charset, $encoding); //mime encode the string $text (the decoded content of $partnumber) using the $charset and $encoding method found in the message $part } } //end if encoding field exists } //end if the content of the message can be retrieved (by $partNumber) of the specified message (by $mid) }//end if the MIME Type is returned and there is no attachment return $text; } //end if NOT multipart message $part=null; //(re)set $text to null } // end if $part is null and $mid is not null } //end try catch (\Exception $ex) { $this->getLastError($ex); } /* This segment is from the original class.mailfetch.php and has not yet been translated to Zend\Mail //Do recursive search $text=''; if($struct && $struct->parts) { while(list($i, $substruct) = each($struct->parts)) { if($partNumber) $prefix = $partNumber . '.'; if(($result=$this->getPart($mid, $mimeType, $encoding, $substruct, $prefix.($i+1)))) $text.=$result; } //end while }// end if return $text; //*/ } /** * Searches the attribute list for a possible filename attribute. If * found, the attribute value is returned. If the attribute uses rfc5987 * to encode the attribute value, the value is returned properly decoded * if possible * * Attribute Search Preference: * filename * filename* * name * name* */ function findFilename($attributes) { foreach (array('filename', 'name') as $pref) { foreach ($attributes as $a) { if (strtolower($a->attribute) == $pref) return $a->value; // Allow the RFC5987 specification of the filename elseif (strtolower($a->attribute) == $pref.'*') return Format::decodeRfc5987($a->value); } } return false; } /* * getAttachments * * search and return a hashtable of attachments.... * NOTE: We're not actually fetching the body of the attachment - we'll do it on demand to save some memory. * */ function getAttachments($mailmsg, $index=0) { try { foreach (new RecursiveIteratorIterator($mailmsg) as $msg_part) { $attachments = false; $filename = false; $encoding = false; $content_id = false; // get the filename if there is one (used to push to findFilename) if (isset($msg_part->contentdisposition)) { if(in_array(strtolower(strtok($msg_part->contentdisposition, ';')),array('attachment', 'inline'))){ $filename = $msg_part->getHeaderField('Content-Disposition', 'filename'); } } $content_id = (isset($msg_part->contentid))?rtrim(ltrim($msg_part->contentid, '<'), '>'):false; //if there is a content-id use it otherwise false $charset = (isset($msg_part->contenttype))?$msg_part->getHeaderField('Content-Type', 'charset'):false; //if the content type is define get the charset, otherwise false $encoding = (isset($msg_part->contenttransferencoding))?$msg_part->contenttransferencoding:false; //if there is an encoding value use it or else false if ($filename) { return array( array( 'name' => $this->mime_decode($filename, $charset, $encoding), 'type' => $this->getMimeType($msg_part), 'encoding' => $encoding, 'index' => ($index?$index:1), 'cid' => $content_id, ) ); } } //end foreach message /* This segment is from the original class.mailfetch.php and has not yet been translated to Zend\Mail //Recursive attachment search! $attachments = array(); if($part && $part->parts) { foreach($part->parts as $k=>$struct) { if($index) $prefix = $index.'.'; $attachments = array_merge($attachments, $this->getAttachments($struct, $prefix.($k+1))); } } return $attachments; //*/ } catch (\Exception $ex) { $this->getLastError($ex); } }// end function getAttachments function getHeader($mid) { try { $mailmsg = $this->zmail->getMessage($mid); } catch (\Exception $ex) { $this->getLastError($ex); } return $mailmsg->getHeaders(); } function getPriority($mid) { return Mail_Parse::parsePriority($this->getHeader($mid)); } function getBody($mid) { global $cfg; if ($cfg->isHtmlThreadEnabled()) { if ($body=$this->getPart($mid, 'text/html')) { //Cleanup the html. $body = (trim($body, " <>br/\t\n\r")) ? Format::safe_html($body) : '--'; } elseif ($body=$this->getPart($mid, 'text/plain')) { $body = trim($body) ? sprintf('
%s
', Format::htmlchars($body)) : '--'; } } else { if (!($body=$this->getPart($mid, 'text/plain', $this->charset))) { if ($body=$this->getPart($mid, 'text/html', $this->charset)) { $body = Format::html2text(Format::safe_html($body), 100, false); } } $body = trim($body) ? $body : '--'; } return $body; } function createTicket($mid) { global $ost; if(!($mailinfo = $this->getHeaderInfo($mid))) return false; //Is the email address banned? if($mailinfo['email'] && TicketFilter::isBanned($mailinfo['email'])) { //We need to let admin know... $ost->logWarning('Ticket denied', 'Banned email - '.$mailinfo['email'], false); return true; //Report success (moved or delete) } $vars = $mailinfo; $vars['name']=$this->mime_decode($mailinfo['name']); $vars['subject']=$mailinfo['subject']?$this->mime_decode($mailinfo['subject']):'[No Subject]'; $vars['message']=Format::stripEmptyLines($this->getBody($mid)); $vars['emailId']=$mailinfo['emailId']?$mailinfo['emailId']:$this->getEmailId(); //Missing FROM name - use email address. if(!$vars['name']) $vars['name'] = $vars['email']; //An email with just attachments can have empty body. if(!$vars['message']) $vars['message'] = '-'; if($ost->getConfig()->useEmailPriority()) $vars['priorityId']=$this->getPriority($mid); $ticket=null; $newticket=true; $errors=array(); $seen = false; // Fetch attachments if any. $mailmsg = $this->zmail->getMessage($mid); if($ost->getConfig()->allowEmailAttachments() //&& ($struct = @mailparse_msg_get_structure($mailmsg)) && ($attachments=$this->getAttachments($mailmsg))) { $vars['attachments'] = array(); foreach($attachments as $a ) { $file = array('name' => $a['name'], 'type' => $a['type']); //Check the file type if(!$ost->isFileTypeAllowed($file)) { $file['error'] = 'Invalid file type (ext) for '.Format::htmlchars($file['name']); } else { // only fetch the body if necessary $self = $this; $file['data'] = function() use ($self, $mid, $a) { return $self->decode($mailmsg->getPart($a['index']), $a['encoding']); }; } // Include the Content-Id if specified (for inline images) $file['cid'] = isset($a['cid']) ? $a['cid'] : false; $vars['attachments'][] = $file; } } $seen = false; if (($thread = ThreadEntry::lookupByEmailHeaders($vars, $seen)) && ($message = $thread->postEmail($vars))) { if (!$message instanceof ThreadEntry) // Email has been processed previously return $message; $ticket = $message->getTicket(); } elseif ($seen) { // Already processed, but for some reason (like rejection), no // thread item was created. Ignore the email return true; } elseif (($ticket=Ticket::create($vars, $errors, 'Email'))) { $message = $ticket->getLastMessage(); } else { //Report success if the email was absolutely rejected. if(isset($errors['errno']) && $errors['errno'] == 403) { // Never process this email again! ThreadEntry::logEmailHeaders(0, $vars['mid']); return true; } # check if it's a bounce! if($vars['header'] && TicketFilter::isAutoBounce($vars['header'])) { $ost->logWarning('Bounced email', $vars['message'], false); return true; } //TODO: Log error.. return null; } return $ticket; } function fetchEmails() { if(!$this->zmail) $this->open(); $archiveFolder = $this->getArchiveFolder(); $delete = $this->canDeleteEmails(); $max = $this->getMaxFetch(); $zmail_config = array('host' => $this->getHost(), 'port' => $this->getPort(), 'ssl' => $this->getEncryption(), 'user' => $this->getUsername(), 'password' => $this->getPassword()); if (strcasecmp($zmail_config['protocol'],'Imap')){ $this->zmail = new Zend\Mail\Storage\Imap($zmail_config); } else { $this->zmail = new Zend\Mail\Storage\Pop3($zmail_config); } $nummsgs = $this->zmail->countMessages(); $msgs = $errors = 0; for($i = $nummsgs; $i > 0; $i--) { //process messages in reverse. if($this->createTicket($i)) { //$ui = $this->zmail->getUniqueId($i); $this->zmail->setFlags($i, Zend\Mail\Storage::FLAG_SEEN); //IMAP only?? if((!$archiveFolder || !$this->zmail->moveMessage($i,$archiveFolder)) && $delete) $this->zmail->removeMessage($i); $msgs++; $errors=0; //We are only interested in consecutive errors. } else { $errors++; } if($max && ($msgs>=$max || $errors>($max*0.8))) break; } //Warn on excessive errors if($errors>$msgs) { $warn=sprintf('Excessive errors processing emails for %s/%s. Please manually check the inbox.', $this->getHost(), $this->getUsername()); $this->log($warn); } return $msgs; } function log($error) { global $ost; $ost->logWarning('Mail Fetcher', $error); } function run() { global $ost; if(!$ost->getConfig()->isEmailPollingEnabled()) return; //We require ZendMail to fetch emails via IMAP/POP3 //We check here just in case the ZendFramework gets [re]moved post email config... if(!class_exists('Zend\Mail\Storage')) { $msg='osTicket requires Zend Class \"Zend\\Mail\\Storage\" for IMAP/POP3 email fetch to work!'; $ost->logWarning('Mail Fetch Error', $msg); return; } //Hardcoded error control... $MAXERRORS = 5; //Max errors before we start delayed fetch attempts $TIMEOUT = 10; //Timeout in minutes after max errors is reached. $sql=' SELECT email_id, mail_errors FROM '.EMAIL_TABLE .' WHERE mail_active=1 ' .' AND (mail_errors<='.$MAXERRORS.' OR (TIME_TO_SEC(TIMEDIFF(NOW(), mail_lasterror))>'.($TIMEOUT*60).') )' .' AND (mail_lastfetch IS NULL OR TIME_TO_SEC(TIMEDIFF(NOW(), mail_lastfetch))>mail_fetchfreq*60)' .' ORDER BY mail_lastfetch DESC' .' LIMIT 10'; //Processing up to 10 emails at a time. if(!($res=db_query($sql)) || !db_num_rows($res)) return; /* Failed query (get's logged) or nothing to do... */ while(list($emailId, $errors)=db_fetch_row($res)) { $fetcher = new MailFetcher($emailId); if($fetcher->open()) { db_query('UPDATE '.EMAIL_TABLE.' SET mail_errors=0, mail_lastfetch=NOW() WHERE email_id='.db_input($emailId)); $fetcher->fetchEmails(); $fetcher->close(); } else { db_query('UPDATE '.EMAIL_TABLE.' SET mail_errors=mail_errors+1, mail_lasterror=NOW() WHERE email_id='.db_input($emailId)); if(++$errors>=$MAXERRORS) { //We've reached the MAX consecutive errors...will attempt logins at delayed intervals $msg="\nosTicket is having trouble fetching emails from the following mail account: \n". "\nUser: ".$fetcher->getUsername(). "\nHost: ".$fetcher->getHost(). "\nError: ".$fetcher->getLastError(). "\n\n ".$errors.' consecutive errors. Maximum of '.$MAXERRORS. ' allowed'. "\n\n This could be connection issues related to the mail server. Next delayed login attempt in approx. $TIMEOUT minutes"; $ost->alertAdmin('Mail Fetch Failure Alert', $msg, true); } } } //end while. } } //end Class MailFetcher ?>