<?php
/**
 * @brief		Incoming email parsing and routing
 * @since		12 June 2013
 */

namespace IPS\Email\Incoming;

/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * PHP Email Class
 * @see	<a href="https://tools.ietf.org/html/rfc5322">RFC 5322</a>
 */
class _Email
{
	/**
	 * @brief	"To" email addresses
	 */
	public $to = array();
	
	/**
	 * @brief	"CC" email addresses
	 */
	public $cc = array();

	/**
	 * @brief	"From" email address
	 */
	public $from = '';
	
	/**
	 * @brief	Subject
	 */
	public $subject = '';
	
	/**
	 * @brief	Message (sanatised HTML)
	 */
	public $message = '';
	
	/**
	 * @brief	Quoted part of the message (sanatised HTML)
	 */
	public $quote = '';
	
	/**
	 * @brief	Attachments
	 */
	public $attachments = array();

	/**
	 * @brief	Existing attachments to store a map for
	 */
	public $mappedAttachments = array();
	
	/**
	 * @brief	Raw email headers
	 */
	public $headers = array();
	
	/**
	 * @brief	Raw email contents
	 */
	public $raw = '';
	
	/**
	 * Constructor
	 *
	 * @param	string	$contents	Contents
	 * @return	void
	 */
	public function __construct( $contents )
	{
		/* Extract the raw data */
	    $this->raw = $contents;
	    $data = $this->_decodePart( $contents );
	    
	    /* Parse headers into something we can use */
	    $this->headers = $data['headers'];
	    $this->_parseBasicHeaders( $data['headers'] );
	    
	    /* Get the message contents */
	    $this->_parseMessagePart( $data );
	}
	
	/* !Raw Email Parsing */
	
	/**
	 * Decode message part
	 *
	 * @param	string	$contents	Contents
	 * @return	array	headers, parts, body
	 */
	protected function _decodePart( $contents )
	{		
		/* Separate header and body */
		preg_match( "/^(.*?)\r?\n\r?\n(.*)/s", $contents, $matches );
		$header = $matches[1];
		$body = $matches[2];
		
		/* Unfold headers (see section 2.2.3 of the RFC) */
		$header = preg_replace( "/\r?\n/", "\r\n", $header );
		$header = preg_replace( "/=\r\n(\t| )+/", '= ', $header );
		$header = preg_replace( "/\r\n(\t| )+/", ' ', $header );
		
		/* Parse headers */
		$headers = array();
		foreach( explode( "\r\n", trim( $header ) ) as $line )
		{
			/* Extract the value */
			$colonPosition = mb_strpos( $line, ':' );
			$headerName = mb_strtolower( mb_substr( $line, 0, $colonPosition ) );
			$headerValue = $this->_decodeHeaderValue( mb_substr( $line, $colonPosition + ( ( mb_substr( $line, $colonPosition + 1, 1 ) === ' ' ) ? 2 : 1 ) ) );
			
			/* Decode the value */			
			/* Save */
			if ( isset( $headers[ $headerName ] ) )
			{
				if ( \is_array( $headers[ $headerName ] ) )
				{
					$headers[ $headerName ][] = $headerValue;
				}
				else
				{
					$headers[ $headerName ] = array( $headers[ $headerName ], $headerValue );
				}
			}
			else
			{
				$headers[ $headerName ] = $headerValue;
			}
		}
				
		/* Work out the content type */
		if ( isset( $headers['content-type'] ) and \is_string( $headers['content-type'] ) )
		{
			$contentType = $this->_parseStructuredHeader( $headers['content-type'] );
		}
		else
		{
			$contentType = array( 'value' => 'text/plain', 'extra' => array() );
		}

		/* Parse body: Multipart */
		if ( \in_array( $contentType['value'], array( 'multipart/parallel', 'multipart/appledouble', 'multipart/report', 'multipart/signed', 'multipart/digest', 'multipart/alternative', 'multipart/related', 'multipart/mixed', 'application/vnd.wap.multipart.related' ) ) and isset( $contentType['extra']['boundary'] ) )
		{
			$parts = array();

			/* Sometimes the email will start with "This is a multi-part message in MIME format" before the first part, so we need to strip that */
			$body = preg_replace( "/(.*?)(--" . preg_quote( $contentType['extra']['boundary'], '/') . ".*)$/s", "$2", $body );

			foreach ( array_filter( preg_split( "/--" . preg_quote( $contentType['extra']['boundary'], '/') . "((?=\s)|--)/", $body ) ) as $part )
			{
				if ( trim( $part ) )
				{
					$parts[] = $this->_decodePart( $part );
				}
			}

			return array( 'headers' => $headers, 'parts' => $parts );
		}
		
		/* Parse body: Included message */
		elseif ( $contentType['value'] == 'message/rfc822' )
		{
			return array( 'headers' => $headers, 'parts' => array( $this->_decodePart( $body ) ) );
		}
		
		/* Parse body: Normal */
		else
		{
			/* Work out the encoding */
			$encoding = null;
			if ( isset( $headers['content-transfer-encoding'] ) )
			{
				$encoding = trim( mb_strtolower( $headers['content-transfer-encoding'] ) );
				if ( mb_strpos( $encoding, ';' ) !== FALSE )
				{
					$encoding = trim( mb_substr( $encoding, mb_strpos( $encoding, ';' ) ) );
				}
			}
			
			/* Decode it */
			if ( $encoding == 'quoted-printable' )
			{
				$body = preg_replace( "/=\r?\n/", '', $body );
				
				$body = preg_replace_callback( '/=([a-f0-9]{2})/i', function( $matches )
				{
					return \chr( hexdec( $matches[1] ) );
				}, $body );
			}
			elseif( $encoding == 'base64' )
			{
				$body = base64_decode( $body );
			}
			
			/* Add it to the array */
			return array( 'headers' => $headers, 'body' => $body );
		}
	}
	
	/**
	 * Decode header value
	 *
	 * @param	string	$headerValue	Value
	 * @return	array
	 */
	protected function _decodeHeaderValue( $headerValue )
	{
		$headerValue = preg_replace( '/(=\?[^?]+\?(q|b)\?[^?]*\?=)(\s)+=\?/i', '\1=?', $headerValue );
        while ( preg_match( '/(=\?([^?]+)\?(q|b)\?([^?]*)\?=)/i', $headerValue, $matches ) )
        {
            $encoded  = $matches[1];
            $charset  = $matches[2];
            $encoding = $matches[3];
            $text     = $matches[4];

            switch ( mb_strtolower( $encoding ) )
            {
                case 'b':
                    $text = base64_decode( $text );
                    break;

                case 'q':
                    $text = str_replace( '_', ' ', $text );
                    preg_match_all( '/=([a-f0-9]{2})/i', $text, $matches );
                    foreach( $matches[1] as $value )
                    {
						$text = str_replace( '=' . $value, \chr( hexdec( $value ) ), $text );
					}
                    break;
            }

			/* We need to convert the text to UTF-8 if it is not already */
			if ( mb_strtolower( $charset ) != 'utf-8' and ( in_array( mb_strtolower( $charset ), array_map( 'mb_strtolower', mb_list_encodings() ) ) ) )
			{
				$text = mb_convert_encoding( $text, 'UTF-8', mb_strtoupper( $charset ) );
			}

            $headerValue = str_replace( $encoded, $text, $headerValue );
        }
        return $headerValue;
	}
	
	/**
	 * Parse a structured header
	 * i.e. foo="bar";baz="moo"
	 * e.g. Content-Type: multipart/alternative; boundary=xxxxxx
	 *
	 * @param	string	$value	Value
	 * @return	array
	 */
	protected function _parseStructuredHeader( $value )
	{
		$extra = array();
		$semiColonPosition = mb_strpos( $value, ';' );
		if ( $semiColonPosition !== false )
		{
			foreach ( explode( ';', trim( mb_substr( $value, $semiColonPosition + 1 ) ) ) as $extraPart )
			{
				$equalsPosition = mb_strpos( $extraPart, '=' );
				$extra[ trim( mb_substr( $extraPart, 0, $equalsPosition ) ) ] = trim( mb_substr( $extraPart, $equalsPosition + 1 ), " \t\n\r\0\x0B'\"" );
			}
			$value = trim( mb_substr( $value, 0, $semiColonPosition ) );
		}
		
		return array( 'value' => mb_strtolower( $value ), 'extra' => $extra );
	}
		
	/* !Secondary Email parsing (extract common headers, get the message body) */
	
	/**
	 * Parse basic headers (to, from, subject, cc)
	 *
	 * @param	array	$headers	Raw headers
	 * @return	void
	 */
	protected function _parseBasicHeaders( $headers )
	{
		/* To */
		if ( $headers['to'] )
		{
			if ( mb_strpos( $headers['to'], ',' ) === FALSE )
			{
				$this->to = array( $headers['to'] );
			}
			/* We need to account for formats like "Doe, John (Contractor)" <a@b.com>, "Doe, Jane" <z@b.com> */
			elseif( mb_strpos( $headers['to'], '"' ) !== FALSE )
			{
				/* The regex here is looking for opening quote, anything except <, comma, anything, closing quote and then stripping the comma so that
					commas are only used to separate the actual addresses */
				$this->to = explode( ',', preg_replace( "/\"([^<]*?),(.*?)\"/", "\"$1 $2\"", $headers['to'] ) );
			}
			else
			{
				$this->to = explode( ',', $headers['to'] );
			}
			$this->to = array_map( array( $this, '_extractEmailAddress' ), $this->to );
		}

		/* From */
		$this->from = $this->_extractEmailAddress( $headers['from'] );

		/* Subject */
		$this->subject = ( (bool) trim( $headers['subject'] ) ) ? $headers['subject'] : \IPS\Member::load( $this->from, 'email' )->language()->get('incoming_email_no_subject');

		/* CC */
		if( isset( $headers['cc'] ) )
		{			
			if( \is_array( $headers['cc'] ) )
			{
				$this->cc = $headers['cc'];
			}
			/* We need to account for formats like "Doe, John (Contractor)" <a@b.com>, "Doe, Jane" <z@b.com> */
			elseif( mb_strpos( $headers['cc'], '"' ) !== FALSE )
			{
				/* The regex here is looking for opening quote, anything except <, comma, anything, closing quote and then stripping the comma so that
					commas are only used to separate the actual addresses */
				$this->cc = explode( ',', preg_replace( "/\"([^<]*?),(.*?)\"/", "\"$1 $2\"", $headers['cc'] ) );
			}
			else
			{
				$this->cc = explode( ",", $headers['cc'] );
			}
			$this->cc = array_map( array( $this, '_extractEmailAddress' ), $this->cc );
		}
	}
	
	/**
	 * Extract the email address from a "Name <email@example.com>" format
	 *
	 * @param	string	$contents	The content to extract from
	 * @return	string
	 */
	protected function _extractEmailAddress( $contents )
	{
		if ( preg_match( "/.+? <(.+?)>/", $contents, $matches ) )
		{
			return $matches[1];
		}
		else
		{
			return trim( trim( $contents ), '<>' );
		}
	}
	
	/**
	 * Parse message part
	 *
	 * @param	array	$data	As returned by _decodePart
	 * @return	void
	 */
	protected function _parseMessagePart( $data )
	{
		/* If this part contains sub-parts, parse recursively... */
		if ( isset( $data['parts'] ) )
		{
			$this->_parseMultipartPart( $data['headers'], $data['parts'] );
		}
				
		/* Otherwise just add to the body */
		elseif ( isset( $data['body'] ) )
		{
			/* If it's text which isn't an attachment, add it */
			$contentType = $this->_parseStructuredHeader( isset( $data['headers']['content-type'] ) ? $data['headers']['content-type'] : 'text/plain' );
			$contentDisposition = isset( $data['headers']['content-disposition'] ) ? $this->_parseStructuredHeader( $data['headers']['content-disposition'] ) : NULL;
			if ( mb_substr( $contentType['value'], 0, 5 ) === 'text/' and ( !$contentDisposition or $contentDisposition['value'] != 'attachment' ) )
			{				
				$body = $this->_parseBodyAsHtml( $data['body'], $contentType );
				$quote = $this->_extractQuoteFromBody( $body );
				$this->message .= $body;
				$this->quote .= $quote;
			}
			
			/* Otherwise make it an attachment */
			else
			{					
				/* If there is no filename, which will often happen with inline disposition, create a random one */
				if( isset( $contentDisposition['extra']['filename'] ) )
				{
					$filename = $contentDisposition['extra']['filename'];
				}
				elseif ( isset( $contentType['extra']['name'] ) )
				{
					$filename = $contentType['extra']['name'];
				}
				else
				{
					$filename = md5( mt_rand() );
				}

				try
				{
					/* Save the file */
					$file = \IPS\File::create( 'core_Attachment', $filename, $data['body'] );
					
					/* Add to the message */
					if ( $file->isImage() )
					{
						if ( isset( $data['headers']['content-id'] ) and $contentId = trim( $data['headers']['content-id'], '<>' ) and isset( $this->imagePlaceholders[ $contentId ] ) )
						{
							$imageTag = "<img src='<fileStore.core_Attachment>/{$file}' class='ipsImage ipsImage_thumbnailed'> ";				
							$this->message = preg_replace( "/<a href=\"#image-placeholder={$this->imagePlaceholders[ $contentId ]}\" rel=\"[^\"]*\"><\/a>/", $imageTag, $this->message );
						}
						else
						{
							$this->message .= "<img src='<fileStore.core_Attachment>/{$file}' class='ipsImage ipsImage_thumbnailed'> ";
						}
					}
					else
					{
						$this->message .= "<a class='ipsAttachLink' href='<fileStore.core_Attachment>/{$file}'>{$file->originalFilename}</a><br><br>";
					}
					
					/* Save to the $attachments array and exit */
					$this->attachments[] = $file;
				}
				catch( \Exception $e )
				{
					\IPS\Log::log( $e, 'incoming_email' );

					/* This could be a corrupt file, which we can't do anything about */
				}
			}
		}
	}
	
	/**
	 * Parse multipart parts
	 *
	 * @param	array	$headers	Raw headers for this part
	 * @param	array	$parts		Raw message sub-parts for this part
	 * @return	void
	 */
	protected function _parseMultipartPart( $headers, $parts )
	{
		$contentType = $this->_parseStructuredHeader( $headers['content-type'] );
		
		/* multipart/alternative is the same content in different formats (usually html and plaintext) */
		if ( $contentType['value'] == 'multipart/alternative' )
		{
			$preferredPart = null;
			
			foreach ( $parts as $part )
			{
				if ( $preferredPart === null )
				{
					$preferredPart = $part;
				}
				else
				{
					$preferredPartContentType = $this->_parseStructuredHeader( $preferredPart['headers']['content-type'] );
					$thisPartContentType = $this->_parseStructuredHeader( $part['headers']['content-type'] );
					
					if ( $preferredPartContentType['value'] == 'text/plain' and $thisPartContentType['value'] != 'text/plain' )
					{
						$preferredPart = $part;
					}
				}
			}
						
			$this->_parseMessagePart( $preferredPart );
		}
		
		/* Everything else (normally multipart/mixed) we'll assume to be broken into multiple parts (for example, attachments) */
		else
		{
			foreach ( $parts as $part )
			{
				$this->_parseMessagePart( $part );
			}
		}
		
	}
	
	/**
	 * @brief Image placeholders
	 */
	protected $imagePlaceholders = array();
	
	/**
	 * Turn body into sanatised HTML for use within Invision Community
	 *
	 * @param	string	$body			Email body
	 * @param	array	$contentType	Content-Type data (as returned by _parseStructuredHeader)
	 * @return	void
	 */
	protected function _parseBodyAsHtml( $body, $contentType )
	{
		/* If it's plaintext, nl2br */
		if ( $contentType['value'] == 'text/plain' )
		{
			$body = nl2br( $body );
		}
				
		/* Convert to UTF-8 if possible */
		if ( isset( $contentType['extra']['charset'] ) and mb_strtolower( $contentType['extra']['charset'] ) != 'utf-8' )
		{
			if( \in_array( mb_strtolower( $contentType['extra']['charset'] ), array_map( 'mb_strtolower', mb_list_encodings() ) ) )
			{
				$body = mb_convert_encoding( $body, 'UTF-8', $contentType['extra']['charset'] );
			}
		}
		
		/* Make any images which we haven't parsed yet placeholders */
		$body = preg_replace_callback( '/<img[^>]*src=[\'"]cid:(.+?)[\'"][^>]*>/', array( $this, '_replaceImageWithPlaceholder' ), $body );
		
		/* Clean */
		$parser = new \IPS\Text\Parser( FALSE, NULL, \IPS\Member::load( $this->from, 'email' ), TRUE, TRUE, TRUE, function( $config ) {
			$config->set( 'HTML.TargetBlank', TRUE );
		} );
		$body = $parser->parse( $body );

		/* Store any mapped attachments that will need a mapping stored in the individual incoming email handlers */
		$this->mappedAttachments = array_merge( $this->mappedAttachments, $parser->mappedAttachments );

		/* Return */
		return $body;
	}
	
	/**
	 * Replace an image with a placeholder to be replaced later when it is parsed as an attachment
	 *
	 * @param	array	$matches	Matches from preg_replace
	 * @return	string
	 */
	protected function _replaceImageWithPlaceholder( $matches )
	{
		$id = mt_rand();
		$this->imagePlaceholders[ $matches[1] ] = $id;
		return '<a href="#image-placeholder=' . $id . '" rel=""></a>';
	}
	
	/**
	 * Extract quote from message body
	 *
	 * @param	string	$body			Email body (the quote will be removed from it and returned)
	 * @return	string
	 */
	protected function _extractQuoteFromBody( &$body )
	{
		/* Init */
		$_quote	= array();
		$_body	= array();
		$_seen	= FALSE;
		$inQuote = FALSE;
		
		/* Split into lines - It is possible the email is formatted with <p></p> and not <br> which can confuse the quote selection */
		if ( mb_stristr( $body, '</p>' ) or mb_stristr( $body, '</div>' ) )
		{			
			$body = preg_replace( '#<(p|div)[^>]*?>#', '', $body );
			$body = preg_replace( '#</(p|div)>#', '<br>', $body );
		}		
		$exploded = explode( "<br />", $body );
		if ( \count( $exploded ) === 1 )
		{
			$exploded = explode( '<br>', $body );
		}
		
		/* Loop over each line */		
		foreach ( $exploded as $k => $line )
		{
			$line = trim( $line );

			if ( mb_substr( $line, 0, 1 ) == '>' )
			{
				$line = ltrim( $line, '> ' );
				
				/* If we are just now hitting a quote line, go back one line to see if it's a "on .. wrote:" line */
				if( !$_seen )
				{
					/* Get the last 2 lines we pushed to the body block.  Sometimes you have "on ... wrote:" followed by an empty line. */
					$_last	= array_pop($_body);
					$_last2	= array_pop($_body);

					$_check	= ( !trim($_last) ) ? $_last2 : $_last;

					/* If it ends with a colon push it to the quote block instead, otherwise put it back */
					if( mb_substr( trim($_check), -1 ) == ':' )
					{
						$_quote[]	= $_last;
						$_quote[]	= $_last2;
					}
					else
					{
						$_body[]	= $_last;
						$_body[]	= $_last2;
					}

					/* Don't do this again */
					$_seen	= true;
				}

				$_quote[]	= $line;
			}
			else
			{
				if ( !$inQuote and preg_match( '/(\s|^)-* ?((Original)|(Forwarded)) Message:? ?-*/i', $line ) )
				{
					$line = preg_replace( '/-* ?(Begin )?((Original)|(Forwarded)) Message:? ?-*/i', '', $line );
					$inQuote = TRUE;
				}
				
				if ( $inQuote )
				{
					$_quote[] = $line;
				}
				else
				{
					$_body[] = $line;
				}
			}
		}

		$quote = implode( "<br>", $_quote );
		$message = implode( "<br>", $_body );
		
		/* Parse out <blockquote> tags which are typically used in HTML emails.  Remember that blockquotes
			can be nested and that our own quote routine uses blockquotes, so we have to be careful.  Look for data-ips* attributes to try to weed out our own. */
		if( mb_strpos( $message, '</blockquote>' ) !== FALSE )
		{
			/* First get the position of the last closing blockquote tag. The "13" here is "</blockquote>". */
			$_lastClosingBlockquote	= mb_strrpos( $message, "</blockquote>" ) + 13;
			
			/* Now get the position of the first opening blockquote tag */
			preg_match( '/<blockquote(?! data-ips).+?>/s', $message, $matches, PREG_OFFSET_CAPTURE );
						
			if( $matches[0][1] )
			{						
				/* Check for common "on x so and so wrote:" type lines preceeding this position */
				preg_match_all( '/<div([^>]+?)?>((?!<div).)*:(<br(>| \/>))*\s*<\/div>/sU', $message, $possibleHeaders, PREG_OFFSET_CAPTURE );
				if ( \count( $possibleHeaders ) )
				{
					foreach ( array_reverse( $possibleHeaders[0] ) as $header )
					{
						if ( $header[1] < $matches[0][1] and mb_strpos( mb_substr( $message, $header[1], $matches[0][1] - $header[1] ), "\n" ) === FALSE )
						{
							$matches[0][1]	= $header[1];
							break;
						}
					}
				}
								
				preg_match( '/<div class=[\'"][^>]*?quote[^>]*?[\'"]>(.*?):(<br(>| \/>))?\s*<blockquote(?! data-ips).+?>/s', $message, $header, PREG_OFFSET_CAPTURE );
				if( !empty($header) AND $header[1][1] < $matches[0][1] )
				{
					$matches[0][1]	= $header[1][1];
				}

				/* Now take everything between these positions and move into the quoted content. substr() rather than mb_substr() because the regex we used to get the offset isn't multibyte aware */
				$quote = \substr( $message, $matches[0][1], ( $_lastClosingBlockquote - $matches[0][1] ) );

				/* And finally, remove the quoted stuff from our email message. substr() rather than mb_substr() because the regex we used to get the offset isn't multibyte aware */
				$message = \substr( $message, 0, $matches[0][1] ) . mb_substr( $message, $_lastClosingBlockquote );
			}
		}
		
		/* Make sure each section is valid HTML */
		$body = $message;
		if ( $quote )
		{
			/* Because we have already parsed the body we have to swap these back otherwise HTMLPurifier will think they're invalid
				HTML. The reparsing we do below will put them back automatically. */
			$body = str_replace( '<___base_url___>', rtrim( \IPS\Settings::i()->base_url, '/' ), $body );
			$quote = str_replace( '<___base_url___>', rtrim( \IPS\Settings::i()->base_url, '/' ), $quote );
			
			/* Parse */
			$body = \IPS\Text\Parser::parseStatic( $body, FALSE, NULL, \IPS\Member::load( $this->from, 'email' ) );
			$quote = \IPS\Text\Parser::parseStatic( $quote, FALSE, NULL, \IPS\Member::load( $this->from, 'email' ) );
		}
		
		/* Return */
		return $quote;
	}
	
	/* !Routing */
	
	/**
	 * Route the email and pass off to the appropriate handler
	 *
	 * @return	void
	 */
	public function route()
	{
		/* Ignore auto-responder messages */
		if( $this->isAutoreply() )
		{
			return;
		}
		
		/* Initialize some vars */
		$routed		= FALSE;

		/* Get our filter rules from the database */
		foreach ( \IPS\Db::i()->select( '*', 'core_incoming_emails' ) as $row )
		{
			/* Reset some vars */
			$analyze	= NULL;
			$match		= FALSE;

			/* Field to check */
			switch ( $row['rule_criteria_field'] )
			{
				case 'to':
					$analyze = implode( ',', $this->to );
					break;
					
				case 'from':
					$analyze = $this->from;
					break;
					
				case 'sbjt':
					$analyze = $this->subject;
					break;
					
				case 'body':
					$analyze = $this->message;
					break;
			}

			/* Now check if we match the supplied rule */
			switch ( $row['rule_criteria_type'] )
			{
				case 'ctns':
					$match = (bool) ( mb_strpos( $analyze, $row['rule_criteria_value'] ) !== FALSE );
					break;
					
				case 'eqls':
					if ( mb_strpos( $analyze, ',' ) !== FALSE )
					{
						$match = (bool) \in_array( $analyze, explode( ',', $analyze ) );
					}
					else
					{
						$match = (bool) ( $analyze == $row['rule_criteria_value'] );
					}
					break;
					
				case 'regx':
					$match = (bool) ( preg_match( "/{$row['rule_criteria_value']}/", $analyze ) == 1 );
					break;
			}

			/* Do we have a match? */
			if ( $match === TRUE )
			{
				$routed	= TRUE;
				break;
			}
		}
		
		/* If we are still here, check each app to see if it wants to handle this unrouted incoming email */
		if ( !$routed )
		{
			/* Loop over all apps that have an incoming email extension */
			foreach ( \IPS\Application::appsWithExtension( 'core', 'IncomingEmail', FALSE ) as $dir => $app )
			{
				/* Get all IncomingEmail extension classes for the app */
				$extensions	= $app->extensions( 'core', 'IncomingEmail' );

				if( \count( $extensions ) )
				{
					/* Loop over the extensions */
					foreach( $extensions as $_instance )
					{
						/* And if it returns true, the unrouted email has now been handled.  We can break. */
						if( $routed = $_instance->process( $this ) )
						{
							break;
						}
					}
				}
			}
		}
		
		/* If we are still here, send an "unrouted email" email to the sender */
		if ( !$routed )
		{
			if ( \IPS\Email::hasTemplate( 'core', 'unrouted' ) )
			{
				\IPS\Email::buildFromTemplate( 'core', 'unrouted', $this, \IPS\Email::TYPE_TRANSACTIONAL )->send( $this->from );
			}
		}
	}

	/**
	 * Is this an auto-reply?  Try to detect to prevent auto-reply loops.
	 *
	 * @return	bool
	 * @see		https://github.com/opennorth/multi_mail/wiki/Detecting-autoresponders
	 */
	protected function isAutoreply()
	{
		/* RFC http://tools.ietf.org/html/rfc3834 */
		if( !empty( $this->headers['auto-submitted'] ) AND mb_strtolower( $this->headers['auto-submitted'] ) != 'no' )
		{
			return TRUE;
		}

		/* If any of these headers are present with any values, ignore the email */
		if( !empty( $this->headers['x-autorespond'] ) OR 
			!empty( $this->headers['x-autoreply'] ) OR
			!empty( $this->headers['x-autoreply-From'] ) OR
			!empty( $this->headers['x-mail-autoreply'] )
			)
		{
			return TRUE;
		}

		/* Now we check for a null return-path (which is considered the "proper" way to prevent auto-responses) */
		if( !empty( $this->headers['return-path'] ) AND $this->headers['return-path'] == '<>' )
		{
			return TRUE;
		}

		/* Now check for specific headers with specific values */
		if( !empty( $this->headers['x-autogenerated'] ) AND \in_array( mb_strtolower( $this->headers['x-autogenerated'] ), array( 'forward', 'group', 'letter', 'mirror', 'redirect', 'reply' ) ) )
		{
			return TRUE;
		}
		
		if( !empty( $this->headers['precedence'] ) AND \in_array( mb_strtolower( $this->headers['precedence'] ), array( 'auto_reply', 'list', 'bulk' ) ) )
		{
			return TRUE;
		}

		if( !empty( $this->headers['x-precedence'] ) AND \in_array( mb_strtolower( $this->headers['x-precedence'] ), array( 'auto_reply', 'list', 'bulk' ) ) )
		{
			return TRUE;
		}

		if( !empty( $this->headers['x-fc-machinegenerated'] ) AND mb_strtolower( $this->headers['x-fc-machinegenerated'] ) == 'true' )
		{
			return TRUE;
		}

		if( !empty( $this->headers['x-post-messageclass'] ) AND mb_strtolower( $this->headers['x-post-messageclass'] ) == '9; autoresponder' )
		{
			return TRUE;
		}

		if ( !empty( $this->headers['delivered-to'] ) AND \is_array( $this->headers['delivered-to'] ) )
		{
			foreach( $this->headers['delivered-to'] AS $deliveredTo )
			{
				if ( mb_strtolower( $deliveredTo ) == 'autoresponder' )
				{
					return TRUE;
				}
			}
		}
		
		if( !empty( $this->headers['delivered-to'] ) AND \is_string( $this->headers['delivered-to'] ) AND mb_strtolower( $this->headers['delivered-to'] ) == 'autoresponder' )
		{
			return TRUE;
		}

		/* And finally, some basic checks on the subject line */
		if( mb_stripos( $this->headers['subject'], "out of office: " ) === 0 OR 
			mb_stripos( $this->headers['subject'], "out of office autoreply:" ) === 0 OR
			mb_stripos( $this->headers['subject'], "automatic reply: " ) === 0 OR
			mb_strtolower( $this->headers['subject'] ) === "out of office"
			)
		{
			return TRUE;
		}

		return FALSE;
	}
}