Copyright (c) 2006-2013 osTicket http://www.osticket.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(ZEND_DIR.'autoload.php'); class MailFetcher { var $ht; var $mail; var $mail_storage; var $mail_protocol; var $mbox; var $srvstr; var $charset = 'UTF-8'; function MailFetcher($email, $charset='UTF-8') { //function Email & getMailAccountInfo reside in class.email.php 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; //Mail server string $this->srvstr=sprintf('{%s:%d/%s', $this->getHost(), $this->getPort(), $this->getProtocol()); if(!strcasecmp($this->getEncryption(), 'SSL')) $this->srvstr.='/ssl'; $this->srvstr.='/novalidate-cert}'; //ZendMail Config $mail_config = array( //Mail server info 'host' => $this->getHost(), 'port' => $this->getPort(), 'protocol' => $this->getProtocol(), 'encryption' => $this->getEncryption(), 'username' => $this->getUsername(), 'password' => $this->getPassword(), ); $archiveFolder = $this->getArchiveFolder(); } } function getEmailId() { return $this->ht['email_id']; } function getHost() { return $this->ht['host']; } function getPort() { return $this->ht['port']; } function getProtocol() { if(!strcasecmp($this->ht['protocol'],"imap")) $this->ht['protocol'] = "Imap"; else $this->ht['protocol'] = "Pop3"; return $this->ht['protocol']; } function getEncryption() { return $this->ht['encryption']; } function getUsername() { return $this->ht['username']; } function getPassword() { return $this->ht['password']; } /* osTicket Settings */ function canDeleteEmails() { return $this->ht['delete_mail']; } function getMaxFetch() { return $this->ht['max_fetch']; } function getArchiveFolder() { return $this->ht['archive_folder']; } /* Core */ // Default folder is inbox function open($box='INBOX') { if ($mail_open) $this->close(); $args = array($this->srvstr.$this->mailbox_encode($box), $this->getUsername(), $this->getPassword()); //ZendMail Config $mail_config = array( //Mail server info 'host' => $this->getHost(), 'port' => $this->getPort(), 'protocol' => $this->getProtocol(), 'encryption' => $this->getEncryption(), 'username' => $this->getUsername(), 'password' => $this->getPassword(), ); // Disable Kerberos and NTLM authentication if it happens to be // supported locally or remotely if (version_compare(PHP_VERSION, '5.3.2', '>=')) $args += array(NULL, 0, array( 'DISABLE_AUTHENTICATOR' => array('GSSAPI', 'NTLM'))); if ($mail_config['protocol']=="Imap"){ $mail_storage = new Zend\Mail\Storage\Imap($mail_config); $mail_protocol = new Zend\Mail\Protocol\Imap($mail_config['host'], $mail_config['port'], $mail_config['encryption']); } else { $mail_storage = new Zend\Mail\Storage\Pop3($mail_config); $mail_protocol = new Zend\Mail\Protocol\Pop3($mail_config['host'], $mail_config['port'], $mail_config['encryption']); } $mail_open = $mail_protocol->login($mail_config['username'], $mail_config['password']); return $mail_open; } function close() { if ($mail_config['protocol']=="Imap") $mail_protocol->expunge(); if($mail_protocol->logout()) $mail_open = False; return $mail_open; } function mailcount() { return $mail_storage->countMessages(); } //Get mail boxes. function getMailboxes() { if(!($gm_box = $mail_protocol->listMailbox()) || !is_array($gm_box)) return null; return $gm_box; } //Create a folder. function createMailbox($archiveFolder) { if(!$archiveFolder) return false; return $mail_protocol->create($archiveFolder); } /* check if a folder exists - create one if requested */ function checkMailbox($archiveFolder, $create=false) { if(($mailboxes = $this->getMailboxes()) && array_key_exists($archiveFolder, $mailboxes)) return true; return ($create && $mail_protocol->create($archiveFolder)); } function decode($text) { $text = Zend\Mime\Decode::decodeQuotedPrintable($text); return $text; } //Convert text to desired encoding..defaults to utf8 function mime_encode($text, $charset=null, $encoding='utf-8') { //Thank in part to afterburner return Format::encode($text, $charset, $encoding); } 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')) $mailbox = mb_convert_encoding($mailbox, 'UTF7-IMAP', 'utf-8'); else // XXX: This function has some issues on some versions of PHP $msg_encode = new Zend\Mail\Message($mailbox); $mailbox = $msg_encode->setEncoding("UTF-7"); return $mailbox; } //Generic decoder - resulting text is utf8 encoded -> mirrors imap_utf8 function mime_decode($text, $encoding='utf-8') { $text = iconv_mime_decode($text, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, $encoding); return $text; } function getLastError() { // global $php_errormsg; // return $php_errormsg; return error_get_last(); } function getMimeType($struct) { $mimeType = array('TEXT', 'MULTIPART', 'MESSAGE', 'APPLICATION', 'AUDIO', 'IMAGE', 'VIDEO', 'OTHER'); if(!$struct || !$struct->subtype) return 'TEXT/PLAIN'; return $mimeType[(int) $struct->type].'/'.$struct->subtype; } function getHeaderInfo($mid) { $mailmsg = $mail_storage->getMessage($mid); if(!($headerinfo=$mailmsg->getHeader()) || !$headerinfo->from) return null; $sender=$headerinfo->from[0]; //Just what we need... $header=array('name' =>@$sender->personal, 'email' => trim(strtolower($sender->mailbox).'@'.$sender->host), 'subject'=>@$headerinfo->subject, 'mid' => trim(@$headerinfo->message_id), 'header' => $this->getHeader($mid), 'in-reply-to' => $headerinfo->in_reply_to, 'references' => $headerinfo->references, ); if ($replyto = $headerinfo->reply_to) { $header['reply-to'] = $replyto[0]->mailbox.'@'.$replyto[0]->host; $header['reply-to-name'] = $replyto[0]->personal; } //Try to determine target email - useful when fetched inbox has // aliases that are independent emails within osTicket. $emailId = 0; $tolist = array(); if($headerinfo->to) $tolist = array_merge($tolist, $headerinfo->to); if($headerinfo->cc) $tolist = array_merge($tolist, $headerinfo->cc); if($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>'; return $header; } //search for specific mime type parts....encoding is the desired encoding. function getMimePart($mid, $mimeType, $encoding=false, $struct=null, $partNumber=false) { $mailmsg = $mail_storage->getMessage($mid); if(!$struct && $mid) $struct=@mailparse_msg_get_structure($mailmsg); //Match the mime type. if($struct && !$struct->ifdparameters && strcasecmp($mimeType, $this->getMimeType($struct))==0) { $partNumber=$partNumber?$partNumber:1; if($text = $mailmsg->getPart($partNumber)) { if($struct->encoding==3 or $struct->encoding==4) //base64 and qp decode. $text=$this->decode($text, $struct->encoding); $charset=null; if($encoding) { //Convert text to desired mime encoding... if($struct->ifparameters) { if(!strcasecmp($struct->parameters[0]->attribute,'CHARSET') && strcasecmp($struct->parameters[0]->value,'US-ASCII')) $charset=trim($struct->parameters[0]->value); } $text=$this->mime_encode($text, $charset, $encoding); } return $text; } } //Do recursive search $text=''; if($struct && $struct->parts) { while(list($i, $substruct) = each($struct->parts)) { if($partNumber) $prefix = $partNumber . '.'; if(($result=$this->getMimePart($mid, $mimeType, $encoding, $substruct, $prefix.($i+1)))) $text.=$result; } } 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($part, $index=0) { if($part && !$part->parts) { //Check if the part is an attachment. $filename = false; if ($part->ifdisposition && in_array(strtolower($part->disposition), array('attachment', 'inline'))) { $filename = $this->findFilename($part->dparameters); } // Inline attachments without disposition. if (!$filename && $part->ifparameters && $part->parameters && $part->type > 0) { $filename = $this->findFilename($part->parameters); } if($filename) { return array( array( 'name' => $this->mime_decode($filename), 'type' => $this->getMimeType($part), 'encoding' => $part->encoding, 'index' => ($index?$index:1) ) ); } } //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; } function getPriority($mid) { $mailmsg = $mail_storage->getMessage($mid); return Mail_Parse::parsePriority($mailmsg->getHeader()); } function getMailBody($mid) { $body =''; if ($body = $this->getMimePart($mid,'TEXT/PLAIN', $this->charset)) // The Content-Type was text/plain, so escape anything that // looks like HTML $body=Format::htmlchars($body); elseif ($body = $this->getMimePart($mid,'TEXT/HTML', $this->charset)) { //Convert tags of interest before we striptags $body=str_replace("
", "\n", $body); $body=str_replace(array("
", "
", "
", "
"), "\n", $body); $body=Format::safe_html($body); //Balance html tags & neutralize unsafe tags. } return $body; } //email to ticket 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; 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; } return null; } //Save attachments if any. $mailmsg = $mail_storage->getMessage($mid); if($message && $ost->getConfig()->allowEmailAttachments() && ($struct = @mailparse_msg_get_structure($mailmsg)) && ($attachments=$this->getAttachments($struct))) { 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 TODO: Make it a callback. $file['data'] = $this->decode($mailmsg->getPart($a['index']), $a['encoding']); $message->importAttachment($file); } } return $ticket; } function fetchEmails() { if(!$mail_open) return false; $archiveFolder = $this->getArchiveFolder(); $delete = $this->canDeleteEmails(); $max = $this->getMaxFetch(); $nummsgs=$mail_storage->countMessages(); //echo "New Emails: $nummsgs\n"; $msgs=$errors=0; for($i=$nummsgs; $i>0; $i--) { //process messages in reverse. if($this->createTicket($i)) { $ui = $mail_storage->getUniqueId($i); $mail_storage->setFlags($ui, Zend\Mail\Storage::FLAG_SEEN); //IMAP only?? if((!$archiveFolder || !$mail_storage->moveMessage($ui,$archiveFolder)) && $delete) $mail_storage->removeMessage($ui); $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); } /**************************************************** * MailFetcher::run() * Static function called to initiate email polling ****************************************************/ 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. // echo $sql; 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->$mail_open) { $fetcher->fetchEmails(); $fetcher->close(); db_query('UPDATE '.EMAIL_TABLE.' SET mail_errors=0, mail_lastfetch=NOW() WHERE email_id='.db_input($emailId)); } 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. } } ?>