* Invision Power Services * IP.Board v3.4.6 * Parse Incoming Emails * Last Updated: $Date: 2013-05-08 17:43:20 -0400 (Wed, 08 May 2013) $ * * * @author $Author: bfarber $ * @copyright © 2010 Invision Power Services, Inc. * @license http://www.invisionpower.com/company/standards.php#license * @package IP.Board * @subpackage Kernel * @link http://www.invisionpower.com * @since 25th June 2010 * @version $Revision: 12240 $ */ class incomingEmail { /** * Params */ public $to = ''; public $from = ''; public $subject = ''; public $cc = ''; public $message = ''; public $original_message = ''; public $raw = ''; public $alternative = ''; public $attachments = array(); /** * Debug Mode */ protected $debug_mode = false; /** * Preferred order for alterative parts */ protected $alternativePrefs = array( 'multipart' => array(), // Multipart has to come first as it's the only one which will include attachments sometimes 'text' => array(), ); /** * Constructor */ protected function __construct() { /* Init objects */ $this->registry = ipsRegistry::instance(); $this->DB = $this->registry->DB(); $this->settings =& $this->registry->fetchSettings(); /* Fix up the base URL */ $this->settings['base_url'] = preg_replace( '/^(.+\/).+?\?(.*?)$/', '$1index.php?$2', $this->settings['base_url'] ); /* Get allowed attachment types */ $this->types = array(); $this->DB->build( array( 'select' => '*', 'from' => 'attachments_type' ) ); $this->DB->execute(); while ( $r = $this->DB->fetch() ) { $this->types[ $r['atype_extension'] ] = $r; } /* Set our preferred format */ if ( $this->settings['incoming_emails_textpref'] == 'html' ) { $this->alternativePrefs['text'][] = 'html'; $this->alternativePrefs['text'][] = 'plain'; } else { $this->alternativePrefs['text'][] = 'plain'; $this->alternativePrefs['text'][] = 'html'; } } /** * Main Parse Method * * @param string Raw email contents * @param array Any headers to override * @param bool Can pass true to not actually route */ public static function parse( $email, $override=array(), $doNotRoute=FALSE ) { $obj = new self(); $obj->raw = $email; //----------------------------------------- // Deconstruct it //----------------------------------------- // It raises strict warnings //@error_reporting( E_NONE ); //@ini_set( 'display_errors', 'off' ); require_once ( IPS_KERNEL_PATH . 'PEAR/Mail/mimeDecode.php' );/*noLibHook*/ $decoder = new Mail_mimeDecode( $email ); $mail = $decoder->decode( array( 'include_bodies' => TRUE, 'decode_bodies' => TRUE, 'decode_headers' => TRUE, ) ); //-------------------------------------- // Parse Headers //-------------------------------------- /* To */ if ( isset( $override['to'] ) ) { $obj->to = $override['to']; } else { if ( $mail->headers['delivered-to'] ) { $mail->headers['to'] = $mail->headers['delivered-to']; } $to = array(); if ( strpos( $mail->headers['to'], ',' ) === FALSE ) { $mail->headers['to'] = array( $mail->headers['to'] ); } else { $mail->headers['to'] = explode( ',', $mail->headers['to'] ); } foreach ( $mail->headers['to'] as $_to ) { if ( preg_match( "/.+? <(.+?)>/", $_to, $matches ) ) { $to[] = htmlentities( $matches[1] ); } else { $to[] = htmlentities( trim( $_to, '<>' ) ); } } $obj->to = implode( ',', $to ); } /* From */ if ( isset( $override['from'] ) ) { $obj->from = $override['from']; } else { if ( preg_match( "/.+? <(.+?)>/", $mail->headers['from'], $matches ) ) { $obj->from = htmlentities( $matches[1] ); } else { $obj->from = htmlentities( trim( $mail->headers['from'], '<>' ) ); } } /* Subject */ if ( isset( $override['subject'] ) ) { $obj->subject = $override['subject']; } else { $obj->subject = ( (bool) trim( $mail->headers['subject'] ) ) ? $mail->headers['subject'] : '(No Subject)'; $obj->subject = htmlentities( $obj->subject ); } /* CC */ $mail->headers['cc'] = preg_replace( '/".+?" <(.+?)>/', '$1', $mail->headers['cc'] ); if ( strpos( $mail->headers['cc'], ',' ) === FALSE ) { $mail->headers['cc'] = array( $mail->headers['cc'] ); } else { $mail->headers['cc'] = explode( ',', $mail->headers['cc'] ); } foreach ( $mail->headers['cc'] as $_cc ) { if ( preg_match( "/.+? <(.+?)>/", $_cc, $matches ) ) { $cc[] = $matches[1]; } else { $cc[] = trim( $_cc, '<> ' ); } } $obj->cc = str_replace( array( '>', '<' ), '', implode( ',', $cc ) ); //----------------------------------------- // Ignore? //----------------------------------------- if ( !$obj->debug_mode and !$doNotRoute ) { $escapedFrom = $obj->DB->addSlashes( $obj->from ); $log = $obj->DB->buildAndFetch( array( 'select' => '*', 'from' => 'core_incoming_email_log', 'where' => "log_email='{$escapedFrom}'" ) ); if ( $log['log_id'] ) { $oneMinuteAgo = time() - 60; if ( $log['log_time'] > $oneMinuteAgo && $this->settings['sixty_second_rule'] ) { $ignore = TRUE; } $obj->DB->update( 'core_incoming_email_log', array( 'log_time' => time() ), "log_id={$log['log_id']}" ); if ( $ignore ) { return; } } else { $obj->DB->insert( 'core_incoming_email_log', array( 'log_email' => $obj->from, 'log_time' => time() ) ); } } //----------------------------------------- // Now destruct the message //----------------------------------------- $obj->message = ''; $obj->_parsePart( $mail ); //----------------------------------------- // Purify It //----------------------------------------- //   sometimes breaks $obj->message = str_replace( ' ', ' ', $obj->message ); /* Load */ require_once( IPS_KERNEL_PATH . 'HTMLPurifier/HTMLPurifier.auto.php' ); $config = HTMLPurifier_Config::createDefault(); /* Set Configuration */ $config->set( 'AutoFormat.Linkify', TRUE ); $config->set( 'Core.Encoding', IPS_DOC_CHAR_SET ); $config->set( 'HTML.TargetBlank', TRUE ); $config->set( 'URI.Munge', ipsRegistry::getClass('output')->buildUrl('app=nexus&module=support§ion=redirect&url=%s&key=%t&resource=%r', 'publicNoSession' ) ); $config->set( 'URI.MungeResources', TRUE ); $config->set( 'URI.MungeSecretKey', md5( ipsRegistry::$settings['sql_pass'] . ipsRegistry::$settings['board_url'] . ipsRegistry::$settings['sql_database'] ) ); /* Retain collapse data attribute */ $def = $config->getHTMLDefinition(true); $def->addAttribute( 'blockquote', 'data-author', 'CDATA' ); $def->addAttribute( 'blockquote', 'data-collapsed', 'Number' ); /* Purify */ $purifier = new HTMLPurifier( $config ); $obj->message = $purifier->purify( $obj->message ); //----------------------------------------- // Route //----------------------------------------- if ( $doNotRoute ) { return $obj; } $routed = FALSE; /* Try our routing criteria */ $obj->DB->build( array( 'select' => '*', 'from' => 'core_incoming_emails' ) ); $obj->DB->execute(); while ( $row = $obj->DB->fetch() ) { // What are we looking for? switch ( $row['rule_criteria_field'] ) { case 'to': $analyse = $obj->to; break; case 'from': $analyse = $obj->from; break; case 'sbjt': $analyse = $obj->subject; break; case 'body': $analyse = $obj->message; break; } // Does it match? $match = false; switch ( $row['rule_criteria_type'] ) { case 'ctns': $match = (bool) ( strpos( $analyse, $row['rule_criteria_value'] ) !== FALSE ); break; case 'eqls': if ( strpos( $analyse, ',' ) !== FALSE ) { $match = (bool) in_array( $analyse, explode( ',', $analyse ) ); } else { $match = (bool) ( $analyse == $row['rule_criteria_value'] ); } break; case 'regx': $match = (bool) ( preg_match( "/{$row['rule_criteria_value']}/", $analyse ) == 1 ); break; } // If it matches, give to the app if ( $match ) { $routed = true; if ( $row['rule_app'] != '--' ) { $appdir = IPSLib::getAppDir( $row['rule_app'] ); if ( is_file( $appdir . '/extensions/incomingEmails.php' ) ) { $class = 'incomingEmails_' . $row['rule_app']; require_once( $appdir . '/extensions/incomingEmails.php' );/*noLibHook*/ $class = new $class; $class->process( $obj ); } } break; } } /* Still here? Try all our apps */ if ( !$routed ) { foreach ( ipsRegistry::$applications as $app ) { $file = IPSLib::getAppDir( $app['app_directory'] ) . '/extensions/incomingEmails.php'; if ( file_exists( $file ) ) { require_once( $file ); $class = 'incomingEmails_' . $app['app_directory']; $i = new $class; if ( $routed = $i->handleUnrouted( $obj ) ) { break; } } } } /* STILL Here? Throw the unrouted message */ if ( !$routed ) { $unroutedMessage = @file_get_contents( DOC_IPS_ROOT_PATH . 'interface/email/unrouted.txt' ); if ( $unroutedMessage ) { IPSText::getTextClass('email')->to = $obj->from; IPSText::getTextClass('email')->from = $obj->to; IPSText::getTextClass('email')->subject = "Re: {$obj->subject}"; IPSText::getTextClass('email')->message = nl2br( $unroutedMessage ); IPSText::getTextClass('email')->setHtmlEmail( TRUE ); IPSText::getTextClass( 'email' )->sendMail(); } } } /** * Parse a "part" * * @param stdClass As returned by mailMime_decode */ protected function _parsePart( $part ) { /* Some clients use uppercase, but we check by lowercase */ $part->ctype_primary = strtolower( $part->ctype_primary ); $part->ctype_secondary = strtolower( $part->ctype_secondary ); /* Make sure .txt files get attached as .txt files */ if ( $part->ctype_primary == 'text' and $part->disposition == 'attachment' ) { $part->ctype_primary = 'unknown'; $part->ctype_secondary = 'unknown'; } /* What is this? */ switch ( $part->ctype_primary ) { /* Multipart - means there's more than one part to this part */ case 'multipart': // "Alternative" means there's more than one way to interpret this part, usually means we have plain text and HTML varients if ( $part->ctype_secondary == 'alternative' ) { // First, check if we have html and plaintext if ( !$this->alternative ) { $haveHtml = false; $havePlain = false; foreach ( $part->parts as $p ) { if ( $p->ctype_primary == 'text' ) { if ( $p->ctype_secondary == 'html' ) { $haveHtml = true; } elseif ( $p->ctype_secondary == 'plain' ) { $havePlain = true; } } } } // This means we need to decide which of the parts we prefer $preferredPart = array_shift( $part->parts ); foreach ( $part->parts as $p ) { if ( $this->_isBetter( $preferredPart, $p ) ) { $preferredPart = $p; } } // What did we choose? if ( !$this->alternative and $haveHtml and $havePlain ) { if ( $preferredPart->ctype_secondary == 'html' ) { $this->alternative = 'h'; } elseif ( $preferredPart->ctype_secondary == 'html' ) { $this->alternative = 'p'; } } return $this->_parsePart( $preferredPart ); } // Otherwise, parse all parts foreach ( $part->parts as $p ) { $this->_parsePart( $p ); } return; /* Text */ case 'text': $body = $part->body; $this->original_message .= $body; //----------------------------------------- // Add
tags //----------------------------------------- if ( $part->ctype_secondary != 'html' ) { $body = nl2br( $body ); } //----------------------------------------- // Convert the charset if necessary //----------------------------------------- if ( isset( $part->ctype_parameters['charset'] ) and $part->ctype_parameters['charset'] != IPS_DOC_CHAR_SET ) { // Sometimes the charset will be "xx-ascii" which isn't what we want if ( strpos( $part->ctype_parameters['charset'], 'ascii' ) !== FALSE ) { //$part->ctype_parameters['charset'] = 'iso-8859-1'; } // Convert $body = IPSText::convertCharsets( $body, $part->ctype_parameters['charset'], IPS_DOC_CHAR_SET ); $part->ctype_parameters['charset'] = IPS_DOC_CHAR_SET; } //----------------------------------------- // Parse > style quotes //----------------------------------------- $quoteLevel = 0; $quotableLine = ''; foreach ( explode( "
", $body ) as $k => $line ) { $line = trim( $line ); // We only need to check for opening/closing quotes if this line actually has content if ( $line ) { // If this line starts with less >s than we're expecting, add a close quote tag // Note we strip whitespace when doing this check as sometimes you'll get ">>>" and other times "> > >" if ( substr( str_replace( ' ', '', $line ), 0, $quoteLevel ) != str_repeat( '>', $quoteLevel ) ) { $quoteLevel--; $line = substr( $line, $quoteLevel ); $line .= '[/quote]
'; } else { // Strip out the >s that we're expecting // Note this has to use the regex as sometimes you'll get ">>>" and other times "> > >" $line = trim( preg_replace( '/^(>\s?){' . $quoteLevel . '}/', '', $line ) ); // If, after stripping the expected number of >s, the line still starts with a >, then open a quote tag if ( substr( $line, 0, 1 ) == '>' ) { $quoteLevel++; $line = substr( $line, 1 ); // If we have content to add into the tag, do that, otherwise, a blank quote is fine if ( $quotableLine ) { $this->message = substr( $this->message, 0, strrpos( $this->message, $quotableLine ) ); $this->message .= "[quote collapse='1' name='" . str_replace( array( "\r", "\n" ), ' ', $quotableLine ) . "']"; } else { $this->message .= "[quote collapse='1']"; } } } // Save the content of this line so that if a quote starts on the next line, we can put it in the header of that quote // Only do this if the line ends with a colon (i.e. "On x, y wrote:") and it doesn't contain any quote tags we've added $quotableLine = ''; if ( substr( $line, -1 ) == ':' and strpos( $line, '[' ) === FALSE ) { $quotableLine = $line; } } $this->message .= $line . '
'; } if ( $quoteLevel ) { for ( $i = 0; $i < $quoteLevel; $i++ ) { $this->message .= '[/quote]'; } } //----------------------------------------- // Parse HTML block quotes //----------------------------------------- /* Loop */ preg_match_all( '//s', $this->message, $matches ); while ( !empty( $matches ) and !empty( $matches[0] ) ) { $m = $matches[0][0]; // Get all the content before the quote $stripped = substr( $this->message, 0, strpos( $this->message, $m ) ); // Knock off any breaks or closed divs after it $stripped = trim( str_replace( '', '', $stripped ) ); $stripped = preg_replace( '/((| )*$)/s', '', $stripped ); // Now scan back until we hit some other html $pos = strrpos( $stripped, '>' ) + 1; $stripped = trim( substr( $stripped, $pos ) ); // If it's an acceptable header, use it if ( substr( $stripped, -1, 1 ) == ':' ) { // Chop it out $this->message = substr_replace( $this->message, '', strpos( $this->message, $stripped ), strlen( $stripped ) ); // We have to encode the header so future tags don't replace it $stripped = '{{QUOTE-ENCODE:' . base64_encode( $stripped ) . '}}'; // Replace the blockquote $this->message = substr_replace( $this->message, "[quote collapse='1' name='{$stripped}']", strpos( $this->message, $m ), strlen( $m ) ); } else { // Replace the blockquote $this->message = substr_replace( $this->message, "[quote collapse='1']", strpos( $this->message, $m ), strlen( $m ) ); } // Do the scan again preg_match_all( '//s', $this->message, $matches ); } /* Decode the quote headers we made */ $this->message = preg_replace_callback( '/{{QUOTE-ENCODE:(.+?)}}/', create_function( '$m', 'return str_replace( array( "\r", "\n" ), " ", base64_decode( $m[1] ) );' ), $this->message ); /* Add in closing [/quote] tags */ $this->message = preg_replace( '/<\/blockquote>/', '[/quote]', $this->message ); /* Check we haven't ended up with any blank quotes */ preg_match_all( '/\[quote.+?\](.+?)\[\/quote\]/s', $this->message, $matches ); foreach ( $matches[0] as $k => $m ) { if ( !trim( strip_tags( $matches[1][$k] ) ) ) { $this->message = str_replace( $m, '', $this->message ); } } //----------------------------------------- // Quote out "Forwarded Message" //----------------------------------------- if ( preg_match( '/-{10,}\s+(.+?)\s-{10,}/', $this->message, $matches ) ) { $this->message = str_replace( $matches[0], "[quote collapse='1']", $this->message ); $this->message .= '[/quote]'; } //----------------------------------------- // Look for common quote encapsulation methods //----------------------------------------- if( strpos( $this->message, 'OLK_SRC_BODY_SECTION' ) !== false ) { if ( preg_match( '//ims', $this->message, $matches ) ) { $this->message = str_replace( $matches[0], "[quote collapse='1']{$matches[0]}", $this->message ); if( strpos( $this->message, '' ) !== false ) { $this->message = str_replace( '', "[/quote]", $this->message ); } else { $this->message .= '[/quote]'; } } } if( strpos( $this->message, 'MsoNormal' ) !== false ) // This must be from Outlook { if ( preg_match( '/
.*?

(.+)<\/div>/ims', $this->message, $matches ) ) { $this->message = str_replace( $matches[0], "[quote collapse='1']{$matches[0]}[/quote]", $this->message ); } } // This code does work, however: // --Differing email clients have differing formats, so it's not reliable // --There can be cases where you have [quote collapse='1' name='On date, at time, so and so wrote:'] already, which results in [quote name='[quote ...]'], breaking the parsing /*if( preg_match( '/\bon\s+?.+?,\s*?at.+?,\s*?' . $this->settings['board_name'] . '.+?' . $this->settings['email_out'] . '.+?wrote:/ims', $this->message, $matches ) ) { //----------------------------------------- // If there's an opening

right after this, go there instead // but only if we're not going to strip quotes (otherwise we get a stray "blah blah said:" line) //----------------------------------------- if( strpos( $this->message, "
", strpos( $this->message, $matches[0] ) ) !== false ) { $matches[0] = substr( $this->message, strpos( $this->message, $matches[0] ), strpos( $this->message, "
", strpos( $this->message, $matches[0] ) ) - strpos( $this->message, $matches[0] ) + 5 ); $this->message = str_replace( $matches[0], "{$matches[0]}[quote collapse='1']", $this->message ); $this->message = preg_replace( "/<\/div>\s*?<\/body>/", "[/quote]
", $this->message ); } else { $this->message = str_replace( $matches[0], "{$matches[0]}[quote collapse='1']", $this->message ); if( strpos( $this->message, '' ) !== false ) { $this->message = str_replace( '', "[/quote]", $this->message ); } else { $this->message .= '[/quote]'; } } }*/ /* Are we missing any closing tags? */ $_opening = substr_count( $this->message, '[quote ' ); $_closing = substr_count( $this->message, '[/quote]' ); if( $_opening > $_closing ) { for( $i = $_opening; $i < $_closing; $i++ ) { $this->message .= '[/quote]'; } } //----------------------------------------- // Parse the quote tag //----------------------------------------- //$this->message = preg_replace( "/\[quote collapse='(.*?)'\]/ims", "
", $this->message ); //$this->message = str_replace( "[/quote]", "
", $this->message ); /* Parse BBCode */ IPSText::getTextClass('bbcode')->parse_html = 0; IPSText::getTextClass('bbcode')->parse_nl2br = 1; IPSText::getTextClass('bbcode')->parse_smilies = 1; IPSText::getTextClass('bbcode')->parse_bbcode = 1; IPSText::getTextClass('bbcode')->parsing_section = 'support'; $this->message = IPSText::getTextClass('bbcode')->parseSingleBbcodes( $this->message, 'display', 'quote' ); return; /* Attachments */ default: if ( $part->ctype_primary == '_text' ) { $part->ctype_primary = 'text'; } $mime = "{$part->ctype_primary}/{$part->ctype_secondary}"; $name = $part->ctype_parameters['name'] ? $part->ctype_parameters['name'] : $part->d_parameters['filename']; $name_parts = explode( '.', $name ); $ext = array_pop( $name_parts ); $content = "{ATTACHMENT WITH EXTENSION: {$ext}}"; if ( isset( $this->types[ $ext ] ) and $this->types[ $ext ]['atype_post'] ) { /* Create the file */ $masked_name = md5( uniqid( 'email' ) ) . "-{$name}"; while ( is_file( $this->settings['upload_dir'] . "/{$masked_name}" ) ) { $masked_name = md5( uniqid( 'email' ) . microtime() ) . "-{$name}"; } file_put_contents( $this->settings['upload_dir'] . "/{$masked_name}", $part->body ); /* Store attachment data */ $this->akey++; $this->attachments[ $this->akey ] = array( 'attach_ext' => $ext, 'attach_file' => $name, 'attach_location' => $masked_name, 'attach_is_image' => ( $part->ctype_primary == 'image' ) ? 1 : 0, 'attach_date' => time(), 'attach_filesize' => $part->d_parameters['size'], ); $content = "{ATTACHMENT:{$this->akey}}"; break; } $addedContent = FALSE; if ( isset( $part->disposition ) and $part->disposition == 'inline' ) { $contentId = trim( $part->headers['content-id'], '<>' ); if ( strpos( $this->message, $contentId ) !== FALSE ) { switch ( $part->ctype_primary ) { case 'image': $this->message = preg_replace( "//", $content, $this->message ); $addedContent = TRUE; break; } } } if ( !$addedContent ) { $this->message .= $content; } return; } } /** * Decide if one part is better than another for parsing multipart/alternative * * @param stdClass Part 1 * @param stdClass Part 2 * @return bool TRUE if Part 2 is better than Part 1 */ protected function _isBetter( $part1, $part2 ) { /* Define our types */ $p1Primary = $part1->ctype_primary; $p1Secondary = $part1->ctype_secondary; $p2Primary = $part2->ctype_primary; $p2Secondary = $part2->ctype_secondary; /* If they're the same, return false */ if ( $p1Primary == $p2Primary and $p1Secondary == $p2Secondary ) { return false; } /* Loop through preferences */ foreach ( $this->alternativePrefs as $primary => $secondary ) { // Part 1 has this if ( $p1Primary == $primary ) { // Does part 2 have it to? if ( $p2Primary == $primary ) { // Yes - check secondaries foreach ( $secondary as $s ) { // Part 1 got it first? return false if ( $p1Secondary == $s ) { return false; } // Part 2 got it first - return true elseif ( $p2Secondary == $s ) { return true; } } } else { // No - then part 1 is better return false; } } // Part 2 has this but part 1 doesn't - part 2 is better elseif ( $p2Primary == $primary ) { return true; } } /* Still here? Then we don't have details on this ctype - assume part 1 is better */ return false; } }