Version 4.1.19
This commit is contained in:
1 parent
28bd025b35
commit
2bd5025018
2674 files changed
+233518
-77766
No files matched your search
+1004
-944
File diff suppressed because it is too large.
Load diff
@@ -1,755 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief Incoming email parsing and routing
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Social Suite
|
||||
* @since 12 June 2013
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\Email;
|
||||
|
||||
/* 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
|
||||
*/
|
||||
class _Incoming
|
||||
{
|
||||
/**
|
||||
* @brief Debug mode enabled
|
||||
*/
|
||||
protected $debugMode = FALSE;
|
||||
|
||||
/**
|
||||
* @brief Basic email details we need to store
|
||||
*/
|
||||
public $data = array(
|
||||
'to' => '',
|
||||
'from' => '',
|
||||
'subject' => '',
|
||||
'cc' => '',
|
||||
'message' => '',
|
||||
'quoted' => '',
|
||||
'raw' => '',
|
||||
'rawbody' => '',
|
||||
'alternative' => '',
|
||||
'attachments' => array(),
|
||||
);
|
||||
|
||||
/**
|
||||
* @brief Track attachment increments so we can reference them
|
||||
*/
|
||||
protected $attachmentCount = 0;
|
||||
|
||||
/**
|
||||
* @brief Store a reference to the PEAR mail object
|
||||
*/
|
||||
public $mail = NULL;
|
||||
|
||||
/**
|
||||
* Initialize an instance of the class
|
||||
*
|
||||
* @param string $email Email text complete with headers
|
||||
* @return \IPS\Email\Incoming
|
||||
*/
|
||||
public static function i( $email )
|
||||
{
|
||||
$obj = new static();
|
||||
|
||||
return $obj->parse( $email );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable debug mode
|
||||
*
|
||||
* @param bool $enable Enable or disable
|
||||
* @return \IPS\Email\Incoming
|
||||
*/
|
||||
public function setDebugMode( $enable )
|
||||
{
|
||||
$this->debugMode = (bool) $enable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the email into pieces
|
||||
*
|
||||
* @param string $email Email text complete with headers
|
||||
* @return \IPS\Email\Incoming
|
||||
*/
|
||||
protected function parse( $email )
|
||||
{
|
||||
$this->data['raw'] = $email;
|
||||
|
||||
/* Get the PEAR email library to parse the email */
|
||||
require_once \IPS\ROOT_PATH . "/system/3rd_party/PEAR/Mail/mimeDecode.php";
|
||||
$decoder = new \Mail_mimeDecode( $email );
|
||||
$this->mail = @$decoder->decode( array(
|
||||
'include_bodies' => TRUE,
|
||||
'decode_bodies' => TRUE,
|
||||
'decode_headers' => TRUE,
|
||||
) );
|
||||
|
||||
/* Assign default 'to' addresses */
|
||||
$to = array();
|
||||
if ( mb_strpos( $this->mail->headers['to'], ',' ) === FALSE )
|
||||
{
|
||||
$this->mail->headers['to'] = array( $this->mail->headers['to'] );
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->mail->headers['to'] = explode( ',', $this->mail->headers['to'] );
|
||||
}
|
||||
foreach ( $this->mail->headers['to'] as $_to )
|
||||
{
|
||||
if ( preg_match( "/.+? <(.+?)>/", $_to, $matches ) )
|
||||
{
|
||||
$to[] = $matches[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
$to[] = trim( $_to, '<>' );
|
||||
}
|
||||
}
|
||||
$this->data['to'] = implode( ',', $to );
|
||||
|
||||
/* Assign default 'from' address */
|
||||
if ( preg_match( "/.+? <(.+?)>/", $this->mail->headers['from'], $matches ) )
|
||||
{
|
||||
$this->data['from'] = $matches[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->data['from'] = trim( $this->mail->headers['from'], '<>' );
|
||||
}
|
||||
|
||||
/* Assign the subject */
|
||||
$this->data['subject'] = ( (bool) trim( $this->mail->headers['subject'] ) ) ? $this->mail->headers['subject'] : \IPS\Member::load( $this->data['from'], 'email' )->language()->addToStack('incoming_email_no_subject');
|
||||
|
||||
/* Assign any 'CC' addresses */
|
||||
$cc = array();
|
||||
|
||||
if( !empty($this->mail->headers['cc']) )
|
||||
{
|
||||
if( is_array( $this->mail->headers['cc'] ) )
|
||||
{
|
||||
$this->mail->headers['cc'] = implode( ",", $this->mail->headers['cc'] );
|
||||
}
|
||||
|
||||
$this->mail->headers['cc'] = preg_replace( '/".+?" <(.+?)>/', '$1', $this->mail->headers['cc'] );
|
||||
|
||||
if ( mb_strpos( $this->mail->headers['cc'], ',' ) === FALSE )
|
||||
{
|
||||
$this->mail->headers['cc'] = array( $this->mail->headers['cc'] );
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->mail->headers['cc'] = explode( ',', $this->mail->headers['cc'] );
|
||||
}
|
||||
|
||||
foreach ( $this->mail->headers['cc'] as $_cc )
|
||||
{
|
||||
if ( preg_match( "/.+? <(.+?)>/", $_cc, $matches ) )
|
||||
{
|
||||
$cc[] = $matches[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
$cc[] = trim( $_cc, '<> ' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->data['cc'] = implode( ',', $cc );
|
||||
|
||||
/* Now start processing the body */
|
||||
$this->data['message'] = '';
|
||||
|
||||
$this->_parsePart( $this->mail );
|
||||
|
||||
/* Clean up the message before passing to HTML Purifier */
|
||||
$this->data['message'] = str_replace( ' ', ' ', $this->data['message'] );
|
||||
|
||||
/* Parse the message */
|
||||
$this->data['message'] = \IPS\Text\Parser::parseStatic( $this->data['message'], FALSE, NULL, \IPS\Member::load( $this->data['from'], 'email' ), TRUE, TRUE, TRUE, function( $config ){
|
||||
$config->set( 'HTML.TargetBlank', TRUE );
|
||||
$config->set( 'URI.Munge', (string) \IPS\Http\Url::internal( 'app=core&module=system&controller=redirect&url=%s&key=%t&resource=%r', 'front' ) );
|
||||
$config->set( 'URI.MungeResources', TRUE );
|
||||
$config->set( 'URI.MungeSecretKey', md5( \IPS\Settings::i()->sql_pass . \IPS\Settings::i()->base_url . \IPS\Settings::i()->sql_database ) );
|
||||
});
|
||||
|
||||
/* Parse the quote too if any */
|
||||
if( $this->data['quoted'] )
|
||||
{
|
||||
$this->data['quoted'] = \IPS\Text\Parser::parseStatic( $this->data['quoted'], FALSE, NULL, \IPS\Member::load( $this->data['from'], 'email' ), TRUE, TRUE, TRUE, function( $config ){
|
||||
$config->set( 'HTML.TargetBlank', TRUE );
|
||||
$config->set( 'URI.Munge', (string) \IPS\Http\Url::internal( 'app=core&module=system&controller=redirect&url=%s&key=%t&resource=%r', 'front' ) );
|
||||
$config->set( 'URI.MungeResources', TRUE );
|
||||
$config->set( 'URI.MungeSecretKey', md5( \IPS\Settings::i()->sql_pass . \IPS\Settings::i()->base_url . \IPS\Settings::i()->sql_database ) );
|
||||
});
|
||||
}
|
||||
|
||||
/* And return */
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override basic values extracted from the email.
|
||||
*
|
||||
* @param array $override [Optional] Pass an array that allows you to override data in the email
|
||||
* @return \IPS\Email\Incoming
|
||||
*/
|
||||
public function setOverrides( $override )
|
||||
{
|
||||
/* Are we overriding any of the values? */
|
||||
if( count($override) )
|
||||
{
|
||||
foreach( $override as $type => $value )
|
||||
{
|
||||
$this->data[ $type ] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the email and pass off to the appropriate handler
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function route()
|
||||
{
|
||||
/* If debug mode is enabled, print out the appropriate data - we don't routing to happen in this case */
|
||||
if( $this->debugMode )
|
||||
{
|
||||
print "Is this an autoresponder? ";
|
||||
var_dump($this->isAutoreply());
|
||||
print "<hr>";
|
||||
print $this->data['message'];
|
||||
print '<hr>';
|
||||
print $this->data['quoted'];
|
||||
|
||||
print '<pre>';
|
||||
$this->data['raw'] = htmlspecialchars($this->data['raw'], \IPS\HTMLENTITIES, 'UTF-8', FALSE);
|
||||
$this->data['rawbody'] = htmlspecialchars($this->data['rawbody'], \IPS\HTMLENTITIES, 'UTF-8', FALSE);
|
||||
$this->data['quoted'] = htmlspecialchars($this->data['quoted'], \IPS\HTMLENTITIES, 'UTF-8', FALSE);
|
||||
$this->data['message'] = htmlspecialchars($this->data['message'], \IPS\HTMLENTITIES, 'UTF-8', FALSE);
|
||||
print_r( $this->data );
|
||||
//print_r( $this->mail );
|
||||
print '</pre>';
|
||||
exit;
|
||||
}
|
||||
|
||||
/* 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':
|
||||
$analyse = $this->data['to'];
|
||||
break;
|
||||
|
||||
case 'from':
|
||||
$analyse = $this->data['from'];
|
||||
break;
|
||||
|
||||
case 'sbjt':
|
||||
$analyse = $this->data['subject'];
|
||||
break;
|
||||
|
||||
case 'body':
|
||||
$analyse = $this->data['message'];
|
||||
break;
|
||||
}
|
||||
|
||||
/* Now check if we match the supplied rule */
|
||||
switch ( $row['rule_criteria_type'] )
|
||||
{
|
||||
case 'ctns':
|
||||
$match = (bool) ( mb_strpos( $analyse, $row['rule_criteria_value'] ) !== FALSE );
|
||||
break;
|
||||
|
||||
case 'eqls':
|
||||
if ( mb_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;
|
||||
}
|
||||
|
||||
/* 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' ) )
|
||||
{
|
||||
$email = \IPS\Email::buildFromTemplate( 'core', 'unrouted', $this->data );
|
||||
$email->from = $this->data['to'];
|
||||
$email->subject = 'Re: ' . $this->data['subject'];
|
||||
$email->send( $this->data['from'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this an auto-reply? Try to detect to prevent auto-reply loops.
|
||||
*
|
||||
* @return bool
|
||||
* @link https://github.com/opennorth/multi_mail/wiki/Detecting-autoresponders
|
||||
*/
|
||||
protected function isAutoreply()
|
||||
{
|
||||
/* RFC http://tools.ietf.org/html/rfc3834 */
|
||||
if( !empty($this->mail->headers['auto-submitted']) AND mb_strtolower($this->mail->headers['auto-submitted']) != 'no' )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* If any of these headers are present with any values, ignore the email */
|
||||
if( !empty($this->mail->headers['x-auto-response-suppress']) OR
|
||||
!empty($this->mail->headers['x-autorespond']) OR
|
||||
!empty($this->mail->headers['x-autoreply']) OR
|
||||
!empty($this->mail->headers['x-autoreply-From']) OR
|
||||
!empty($this->mail->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->mail->headers['return-path']) AND $this->mail->headers['return-path'] == '<>' )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* Now check for specific headers with specific values */
|
||||
if( !empty($this->mail->headers['x-autogenerated']) AND in_array( mb_strtolower($this->mail->headers['x-autogenerated']), array( 'forward', 'group', 'letter', 'mirror', 'redirect', 'reply' ) ) )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if( !empty($this->mail->headers['precedence']) AND in_array( mb_strtolower($this->mail->headers['precedence']), array( 'auto_reply', 'list', 'bulk' ) ) )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if( !empty($this->mail->headers['x-precedence']) AND in_array( mb_strtolower($this->mail->headers['x-precedence']), array( 'auto_reply', 'list', 'bulk' ) ) )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if( !empty($this->mail->headers['x-fc-machinegenerated']) AND mb_strtolower($this->mail->headers['x-fc-machinegenerated']) == 'true' )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if( !empty($this->mail->headers['x-post-messageclass']) AND mb_strtolower($this->mail->headers['x-post-messageclass']) == '9; autoresponder' )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
if ( !empty( $this->mail->headers['delivered-to'] ) AND is_array( $this->mail->headers['delivered-to'] ) )
|
||||
{
|
||||
foreach( $this->mail->headers['delivered-to'] AS $deliveredTo )
|
||||
{
|
||||
if ( mb_strtolower( $deliveredTo ) == 'autoresponder' )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if( !empty($this->mail->headers['delivered-to']) AND is_string( $this->mail->headers['delivered-to'] ) AND mb_strtolower($this->mail->headers['delivered-to']) == 'autoresponder' )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/* And finally, some basic checks on the subject line */
|
||||
if( mb_stripos( $this->mail->headers['subject'], "out of office: " ) === 0 OR
|
||||
mb_stripos( $this->mail->headers['subject'], "out of office autoreply:" ) === 0 OR
|
||||
mb_stripos( $this->mail->headers['subject'], "automatic reply: " ) === 0 OR
|
||||
mb_strtolower($this->mail->headers['subject']) === "out of office"
|
||||
)
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = mb_strtolower( $part->ctype_primary );
|
||||
$part->ctype_secondary = mb_strtolower( $part->ctype_secondary );
|
||||
|
||||
/* Make sure .txt files get attached as .txt files */
|
||||
if ( $part->ctype_primary == 'text' AND isset($part->disposition) 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 so we have to figure out which to use */
|
||||
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->data['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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Determine which part is better for us */
|
||||
$preferredPart = array_shift( $part->parts );
|
||||
|
||||
foreach ( $part->parts as $p )
|
||||
{
|
||||
if ( $this->_isBetter( $preferredPart, $p ) )
|
||||
{
|
||||
$preferredPart = $p;
|
||||
}
|
||||
}
|
||||
|
||||
/* What did we choose? */
|
||||
if ( !$this->data['alternative'] and $haveHtml and $havePlain )
|
||||
{
|
||||
if ( $preferredPart->ctype_secondary == 'html' )
|
||||
{
|
||||
$this->data['alternative'] = 'h';
|
||||
}
|
||||
elseif ( $preferredPart->ctype_secondary == 'html' )
|
||||
{
|
||||
$this->data['alternative'] = 'p';
|
||||
}
|
||||
}
|
||||
|
||||
return $this->_parsePart( $preferredPart );
|
||||
}
|
||||
|
||||
/* Otherwise, parse all parts */
|
||||
foreach ( $part->parts as $p )
|
||||
{
|
||||
$this->_parsePart( $p );
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
/* Parse text (email body) */
|
||||
case 'text':
|
||||
$body = $part->body;
|
||||
$this->data['rawbody'] .= $body;
|
||||
|
||||
/* If this is a plaintext email, we need to convert newlines to line breaks */
|
||||
if ( $part->ctype_secondary != 'html' )
|
||||
{
|
||||
$body = nl2br( $body );
|
||||
}
|
||||
|
||||
/* Convert the character set if necessary */
|
||||
if ( isset( $part->ctype_parameters['charset'] ) and mb_strtolower($part->ctype_parameters['charset']) != 'utf-8' )
|
||||
{
|
||||
if( in_array( mb_strtolower($part->ctype_parameters['charset']), array_map( 'mb_strtolower', mb_list_encodings() ) ) )
|
||||
{
|
||||
$body = mb_convert_encoding( $body, 'UTF-8', $part->ctype_parameters['charset'] );
|
||||
$part->ctype_parameters['charset'] = 'UTF-8';
|
||||
}
|
||||
}
|
||||
|
||||
/* Parse out > style quotes - mainly plaintext emails */
|
||||
$_quote = array();
|
||||
$_body = array();
|
||||
$_seen = FALSE;
|
||||
$inQuote = FALSE;
|
||||
$exploded = explode( "<br />", $body );
|
||||
if ( count( $exploded ) === 1 )
|
||||
{
|
||||
$exploded = explode( '<br>', $body );
|
||||
}
|
||||
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( $_quote, "<br />" );
|
||||
$message = implode( $_body, "<br />" );
|
||||
|
||||
/* 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 */
|
||||
$_lastClosingBlockquote = strrpos( $message, "</blockquote>" );
|
||||
|
||||
/* 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( '/<div([^>]+?)?>((?!<div).)*:(<br(>| \/>))?\s*<\/div>/s', $message, $header, PREG_OFFSET_CAPTURE );
|
||||
|
||||
if( !empty($header) AND $header[0][1] < $matches[0][1] )
|
||||
{
|
||||
$matches[0][1] = $header[0][1];
|
||||
}
|
||||
|
||||
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. The "13" here is "</blockquote>". */
|
||||
$quote = mb_substr( $message, $matches[0][1], ( $_lastClosingBlockquote - $matches[0][1] ) + 13 );
|
||||
|
||||
/* And finally, remove the quoted stuff from our email message */
|
||||
$message = substr_replace( $message, '', $matches[0][1], ( $_lastClosingBlockquote - $matches[0][1] ) + 13 );
|
||||
}
|
||||
}
|
||||
|
||||
$this->data['quoted'] .= $quote;
|
||||
$this->data['message'] .= $message;
|
||||
|
||||
return;
|
||||
|
||||
/* Parse attachments */
|
||||
default:
|
||||
|
||||
if( !$part->body )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $part->ctype_primary == '_text' )
|
||||
{
|
||||
$part->ctype_primary = 'text';
|
||||
}
|
||||
|
||||
$this->attachmentCount++;
|
||||
|
||||
/* Get the details for this file */
|
||||
$mime = "{$part->ctype_primary}/{$part->ctype_secondary}";
|
||||
$name = ( !empty($part->ctype_parameters['name']) ) ? $part->ctype_parameters['name'] : $part->d_parameters['filename'];
|
||||
$ext = mb_substr( $name, ( mb_strrpos( $name, '.' ) + 1 ) );
|
||||
|
||||
/* Store the attachment. Later, classes can just call this object ->makeAttachment() passing an appropriate post-key in. */
|
||||
$file = \IPS\File::create( 'core_Attachment', $name, $part->body );
|
||||
$this->data['attachments'][ $this->attachmentCount ] = $file;
|
||||
if ( $file->isImage() )
|
||||
{
|
||||
$this->data['message'] .= "<img src='{$file}' class='ipsImage ipsImage_thumbnailed'>";
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->data['message'] .= "<a class='ipsAttachLink' href='{$file}'>{$file->originalFilename}</a>";
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide if one part is better than another for parsing multipart/alternative
|
||||
*
|
||||
* @param stdClass $part1 Part 1
|
||||
* @param stdClass $part2 Part 2
|
||||
* @return bool
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/* Do we have a multipart email? Use that first, as it will contain the attachments */
|
||||
if( $p1Primary == 'multipart' )
|
||||
{
|
||||
if( $p2Primary != 'multipart' )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if( $p2Primary == 'multipart' )
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Check for a text email - check for html first and then fall back to plaintext */
|
||||
if( $p1Primary == 'text' )
|
||||
{
|
||||
if( $p2Primary == 'text' )
|
||||
{
|
||||
if( $p1Secondary == 'html' )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else if( $p2Secondary == 'html' )
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else if( $p1Secondary == 'plain' )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else if( $p2Secondary == 'plain ')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if( $p2Primary == 'text' )
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/* If we're still here, neither part satisfies our requests - assume Part 1 is better */
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,845 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief Incoming email parsing and routing
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 12 June 2013
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
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 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' )
|
||||
{
|
||||
$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
|
||||
* @retrun void
|
||||
*/
|
||||
protected function _parseBasicHeaders( $headers )
|
||||
{
|
||||
/* To */
|
||||
if ( $headers['to'] )
|
||||
{
|
||||
if ( mb_strpos( $headers['to'], ',' ) === FALSE )
|
||||
{
|
||||
$this->to = array( $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'];
|
||||
}
|
||||
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( uniqid() );
|
||||
}
|
||||
|
||||
/* 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>";
|
||||
}
|
||||
|
||||
/* Save to the $attachments array and exit */
|
||||
$this->attachments[] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 IPS Community Suite
|
||||
*
|
||||
* @param string $body Email body
|
||||
* @param array $contentType Content-Type data (as returned by _parseStructuredHeader)
|
||||
* @retrun 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 */
|
||||
$body = \IPS\Text\Parser::parseStatic( $body, FALSE, NULL, \IPS\Member::load( $this->from, 'email' ), TRUE, TRUE, TRUE, function( $config ) {
|
||||
$config->set( 'HTML.TargetBlank', TRUE );
|
||||
$config->set( 'URI.Munge', urldecode( (string) \IPS\Http\Url::internal( 'app=core&module=system&controller=redirect&url=%s&key=%t&resource=%r', 'front' ) ) );
|
||||
$config->set( 'URI.MungeResources', TRUE );
|
||||
$config->set( 'URI.MungeSecretKey', \IPS\Settings::i()->site_secret_key );
|
||||
});
|
||||
|
||||
/* 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 = md5( uniqid() );
|
||||
$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)
|
||||
* @retrun string
|
||||
*/
|
||||
protected function _extractQuoteFromBody( &$body )
|
||||
{
|
||||
/* Init */
|
||||
$_quote = array();
|
||||
$_body = array();
|
||||
$_seen = FALSE;
|
||||
$inQuote = FALSE;
|
||||
$exploded = explode( "<br />", $body );
|
||||
if ( count( $exploded ) === 1 )
|
||||
{
|
||||
$exploded = explode( '<br>', $body );
|
||||
}
|
||||
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___>', '{___base_url___}', $body );
|
||||
$quote = str_replace( '<___base_url___>', '{___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':
|
||||
$analyse = implode( ',', $this->to );
|
||||
break;
|
||||
|
||||
case 'from':
|
||||
$analyse = $this->from;
|
||||
break;
|
||||
|
||||
case 'sbjt':
|
||||
$analyse = $this->subject;
|
||||
break;
|
||||
|
||||
case 'body':
|
||||
$analyse = $this->message;
|
||||
break;
|
||||
}
|
||||
|
||||
/* Now check if we match the supplied rule */
|
||||
switch ( $row['rule_criteria_type'] )
|
||||
{
|
||||
case 'ctns':
|
||||
$match = (bool) ( mb_strpos( $analyse, $row['rule_criteria_value'] ) !== FALSE );
|
||||
break;
|
||||
|
||||
case 'eqls':
|
||||
if ( mb_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;
|
||||
}
|
||||
|
||||
/* 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 )->send( $this->from );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this an auto-reply? Try to detect to prevent auto-reply loops.
|
||||
*
|
||||
* @return bool
|
||||
* @link 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-auto-response-suppress'] ) OR
|
||||
!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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief POP/IMAP Incoming Email Handler
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 21 December 2015
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
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
|
||||
*/
|
||||
class _PopImap
|
||||
{
|
||||
/**
|
||||
* @brief File pointer
|
||||
*/
|
||||
protected $fh;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $server Server name (e.g. pop.example.com)
|
||||
* @param bool $sslRequired If SSL is required
|
||||
* @param int $port Port
|
||||
* @param string $username Username
|
||||
* @param string $password Password
|
||||
* @return void
|
||||
* @throws \IPS\Email\Incoming\PopImapException
|
||||
*/
|
||||
public function __construct( $server, $sslRequired, $port, $username, $password )
|
||||
{
|
||||
/* Connect */
|
||||
$errorNumber = 0;
|
||||
$errorMessage = '';
|
||||
$this->fh = @fsockopen( ( $sslRequired ? 'ssl://' : '' ) . $server, $port, $errorNumber, $errorMessage );
|
||||
if ( $this->fh === FALSE )
|
||||
{
|
||||
throw new PopImapException( 'pop3_cant_connect' );
|
||||
}
|
||||
|
||||
/* Set stream preferences */
|
||||
stream_set_timeout( $this->fh, \IPS\DEFAULT_REQUEST_TIMEOUT );
|
||||
stream_set_blocking( $this->fh, TRUE );
|
||||
|
||||
/* Get initial response */
|
||||
try
|
||||
{
|
||||
$response = $this->_getLine();
|
||||
}
|
||||
catch ( PopImapException $e )
|
||||
{
|
||||
throw new PopImapException( 'pop3_connect_err' );
|
||||
}
|
||||
|
||||
/* Log In */
|
||||
try
|
||||
{
|
||||
$this->_putLine( "USER {$username}" );
|
||||
$this->_putLine( "PASS {$password}" );
|
||||
}
|
||||
catch ( PopImapException $e )
|
||||
{
|
||||
throw new PopImapException( 'pop3_login_err' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of emails in inbox
|
||||
*
|
||||
* @return int
|
||||
* @throws \IPS\Email\Incoming\PopImapException
|
||||
*/
|
||||
public function emailsInInbox()
|
||||
{
|
||||
$stats = explode( ' ', $this->_putLine( 'STAT' ) );
|
||||
return (int) $stats[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email
|
||||
*
|
||||
* @param int $email The email to fetch
|
||||
* @return string
|
||||
* @throws \IPS\Email\Incoming\PopImapException
|
||||
*/
|
||||
public function getEmail( $i )
|
||||
{
|
||||
$this->_putLine( "RETR {$i}" );
|
||||
|
||||
$message = '';
|
||||
while ( $line = $this->_getLine( FALSE ) and !preg_match( "/^\.\r\n/", $line ) )
|
||||
{
|
||||
$message .= $line;
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete email
|
||||
*
|
||||
* @param int $email The email to delete
|
||||
* @return string
|
||||
* @throws \IPS\Email\Incoming\PopImapException
|
||||
*/
|
||||
public function deleteEmail( $i )
|
||||
{
|
||||
$this->_putLine( "DELE {$i}" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Line
|
||||
*
|
||||
* @param bool $expectOkay The response should start with "+OK"
|
||||
* @return string
|
||||
*/
|
||||
public function _getLine( $expectOkay = TRUE )
|
||||
{
|
||||
$response = fgets( $this->fh, 512 );
|
||||
|
||||
$status = socket_get_status( $this->fh );
|
||||
if ( $status['timed_out'] )
|
||||
{
|
||||
throw new \IPS\Email\Incoming\PopImapException( 'TIMEOUT' );
|
||||
}
|
||||
|
||||
if ( $expectOkay and mb_substr( $response, 0, 3 ) !== '+OK' )
|
||||
{
|
||||
throw new \IPS\Email\Incoming\PopImapException( 'UNEXPECTED_RESPONSE: ' . $response );
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put Line
|
||||
*
|
||||
* @param string $command Command to send
|
||||
* @param bool $expectOkay The response should start with "+OK"
|
||||
* @return string The response
|
||||
*/
|
||||
public function _putLine( $command, $expectOkay = TRUE )
|
||||
{
|
||||
if ( @\fwrite( $this->fh, $command . "\r\n" ) === FALSE )
|
||||
{
|
||||
throw new \IPS\Email\Incoming\PopImapException( 'COULD_NOT_WRITE' );
|
||||
}
|
||||
|
||||
return $this->_getLine( $expectOkay );
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
$this->_putLine( "QUIT", FALSE );
|
||||
fclose( $this->fh );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief POP/IMAP Exception class
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 21 December 2015
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* POP/IMAP Exception class
|
||||
*/
|
||||
class _PopImapException extends \Exception
|
||||
{
|
||||
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
/**
|
||||
* @brief Debug Email Class
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc.
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Social Suite
|
||||
* @package IPS Community Suite
|
||||
* @since 17 Apr 2013
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
@@ -26,45 +26,50 @@ class _Debug extends \IPS\Email
|
||||
/**
|
||||
* @brief Debug path to write the email files
|
||||
*/
|
||||
public $debugPath = '';
|
||||
|
||||
protected $debugPath = '';
|
||||
|
||||
/**
|
||||
* Send
|
||||
*
|
||||
* @return bool
|
||||
* @throws \IPS\Email\Exception
|
||||
* @brief If sending a bulk email to more than MAX_EMAILS_PER_GO - does this
|
||||
* class require waiting between cycles? For "standard" classes like
|
||||
* PHP and SMTP, this will be TRUE - and will cause bulk mails to go
|
||||
* to a class. For APIs like SparkPost, this can be FALSE
|
||||
*/
|
||||
protected function _send()
|
||||
const REQUIRES_TIME_BREAK = TRUE;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $debugPath Debug path
|
||||
* @return void
|
||||
*/
|
||||
public function __construct( $debugPath )
|
||||
{
|
||||
$this->debugPath = $debugPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email
|
||||
*
|
||||
* @param mixed $to The member or email address, or array of members or email addresses, to send to
|
||||
* @param mixed $cc Addresses to CC (can also be email, member or array of either)
|
||||
* @param mixed $bcc Addresses to BCC (can also be email, member or array of either)
|
||||
* @param mixed $fromEmail The email address to send from. If NULL, default setting is used
|
||||
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
|
||||
* @param array $additionalHeaders The name the email should appear from. If NULL, default setting is used
|
||||
* @return void
|
||||
* @throws \IPS\Email\Outgoing\Exception
|
||||
*/
|
||||
public function _send( $to, $cc=array(), $bcc=array(), $fromEmail = NULL, $fromName = NULL, $additionalHeaders = array() )
|
||||
{
|
||||
if( !is_dir( $this->debugPath ) )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( sprintf( \IPS\Member::loggedIn()->language()->get('no_path_email_debug'), $this->debugPath ) );
|
||||
}
|
||||
|
||||
$debugString = $this->subject . "\n";
|
||||
$debugString .= "To: " . implode( ', ', $this->to ) . "\n";
|
||||
$debugString .= str_repeat( '-', 40 ) . "\n";
|
||||
$debugString .= $this->_compileHeaders() . "\n";
|
||||
$debugString .= str_repeat( '-', 40 ) . "\n";
|
||||
$debugString .= $this->message;
|
||||
|
||||
$fileName = date("M-j-Y") . '-' . microtime() . '-' . urlencode( mb_substr( implode( ',', $this->to ), 0, 200 ) ) . ".eml";
|
||||
|
||||
$this->writeLog( $fileName, $debugString );
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the debug file
|
||||
*
|
||||
* @param string $filename File name
|
||||
* @param string $data Data to write to the file
|
||||
* @return void
|
||||
*/
|
||||
protected function writeLog( $filename, $data )
|
||||
{
|
||||
if( !@\file_put_contents( rtrim( $this->debugPath, '/' ) . '/' . $filename, $data ) )
|
||||
|
||||
$fullEmailContents = $this->compileFullEmail( $to, $cc, $bcc, $fromEmail, $fromName, $additionalHeaders );
|
||||
|
||||
$filename = date("M-j-Y") . '-' . microtime() . '-' . urlencode( mb_substr( $this->_parseRecipients( $to, TRUE ), 0, 200 ) ) . ".eml";
|
||||
if( !@\file_put_contents( rtrim( $this->debugPath, '/' ) . '/' . $filename, $fullEmailContents ) )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( \IPS\Member::loggedIn()->language()->addToStack( 'no_write_email_debug', FALSE, array( 'sprintf' => array( rtrim( $this->debugPath, '/' ) . '/' . $filename ) ) ) );
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
/**
|
||||
* @brief Exception class for email errors
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc.
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Social Suite
|
||||
* @package IPS Community Suite
|
||||
* @since 26 Aug 2013
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief IPS CiC Email Class
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 13 Dec 2016
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\Email\Outgoing;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPS CiC Email Class
|
||||
*/
|
||||
class _IpsCic extends \IPS\Email
|
||||
{
|
||||
/* !Configuration */
|
||||
|
||||
/**
|
||||
* @brief The number of emails that can be sent in one "go"
|
||||
*/
|
||||
const MAX_EMAILS_PER_GO = 200;
|
||||
|
||||
/**
|
||||
* @brief If sending a bulk email to more than MAX_EMAILS_PER_GO - does this
|
||||
* class require waiting between cycles? For "standard" classes like
|
||||
* PHP and SMTP, this will be TRUE - and will cause bulk mails to go
|
||||
* to a class. For APIs like SparkPost, this can be FALSE
|
||||
*/
|
||||
const REQUIRES_TIME_BREAK = FALSE;
|
||||
|
||||
/**
|
||||
* @brief IPS Cloud Email Endpoint
|
||||
*/
|
||||
const ENDPOINT = 'http://ips-cic-email.invisioncic.com/sendEmail.php';
|
||||
|
||||
/**
|
||||
* @brief Community
|
||||
*/
|
||||
protected $community;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $apiKey API Key
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
if ( \IPS\CIC and preg_match( '/^\/var\/www\/html\/(.+?)(?:\/|$)/i', \IPS\ROOT_PATH, $matches ) )
|
||||
{
|
||||
$this->community = $matches[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new \RuntimeException('Site not compatible with IPS Cloud Email');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email
|
||||
*
|
||||
* @param mixed $to The member or email address, or array of members or email addresses, to send to
|
||||
* @param mixed $cc Addresses to CC (can also be email, member or array of either)
|
||||
* @param mixed $bcc Addresses to BCC (can also be email, member or array of either)
|
||||
* @param mixed $fromEmail The email address to send from. If NULL, default setting is used
|
||||
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
|
||||
* @param array $additionalHeaders The name the email should appear from. If NULL, default setting is used
|
||||
* @return void
|
||||
* @throws \IPS\Email\Outgoing\Exception
|
||||
*/
|
||||
public function _send( $to, $cc=array(), $bcc=array(), $fromEmail = NULL, $fromName = NULL, $additionalHeaders = array() )
|
||||
{
|
||||
\IPS\Http\Url::external( static::ENDPOINT )->request()->post( array(
|
||||
'siteId' => $this->community,
|
||||
'to' => explode( ',', static::_parseRecipients( $to, TRUE ) ),
|
||||
'cc' => explode( ',', static::_parseRecipients( $cc, TRUE ) ),
|
||||
'bcc' => explode( ',', static::_parseRecipients( $bcc, TRUE ) ),
|
||||
'fromEmail' => $fromEmail ?: \IPS\Settings::i()->email_out,
|
||||
'fromName' => $fromName ?: \IPS\Settings::i()->board_name,
|
||||
'additionalHeaders' => $additionalHeaders,
|
||||
'subject' => $this->compileSubject( static::_getMemberFromRecipients( $to ) ),
|
||||
'html' => $this->compileContent( 'html', static::_getMemberFromRecipients( $to ) ),
|
||||
'plaintext' => $this->compileContent( 'plaintext', static::_getMemberFromRecipients( $to ) ),
|
||||
'precedence' => ( $this->type === static::TYPE_LIST ) ? 'list' : ( $this->type === static::TYPE_BULK ? 'bulk' : '' )
|
||||
) );
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
/**
|
||||
* @brief PHP Email Class
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc.
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Social Suite
|
||||
* @package IPS Community Suite
|
||||
* @since 17 Apr 2013
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
@@ -24,15 +24,34 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
||||
class _PHP extends \IPS\Email
|
||||
{
|
||||
/**
|
||||
* Send
|
||||
*
|
||||
* Send the email
|
||||
*
|
||||
* @param mixed $to The member or email address, or array of members or email addresses, to send to
|
||||
* @param mixed $cc Addresses to CC (can also be email, member or array of either)
|
||||
* @param mixed $bcc Addresses to BCC (can also be email, member or array of either)
|
||||
* @param mixed $fromEmail The email address to send from. If NULL, default setting is used
|
||||
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
|
||||
* @param array $additionalHeaders The name the email should appear from. If NULL, default setting is used
|
||||
* @return void
|
||||
* @throws \IPS\Email\Outgoing\Exception
|
||||
*/
|
||||
protected function _send()
|
||||
{
|
||||
try
|
||||
public function _send( $to, $cc=array(), $bcc=array(), $fromEmail = NULL, $fromName = NULL, $additionalHeaders = array() )
|
||||
{
|
||||
$boundary = "--==_mimepart_" . md5( uniqid() );
|
||||
|
||||
$subject = $this->compileSubject( static::_getMemberFromRecipients( $to ) );
|
||||
$headers = array();
|
||||
foreach( $this->_compileHeaders( $subject, $to, $cc, $bcc, $fromEmail, $fromName, $additionalHeaders, $boundary ) as $k => $v )
|
||||
{
|
||||
if ( !mail( $this->headers['To'], mb_encode_mimeheader( $this->subject ), $this->message, $this->_compileHeaders( TRUE ), \IPS\Settings::i()->php_mail_extra ) )
|
||||
if ( !in_array( $k, array( 'To', 'Subject' ) ) )
|
||||
{
|
||||
$headers[] = "{$k}: {$v}";
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if ( !mail( $this->_parseRecipients( $to, TRUE ), static::encodeHeader( $subject ), $this->_compileMessage( static::_getMemberFromRecipients( $to ), $boundary, PHP_EOL ), implode( PHP_EOL, $headers ), \IPS\Settings::i()->php_mail_extra ) )
|
||||
{
|
||||
$error = error_get_last();
|
||||
preg_match( "/^(\d+)/", $error['message'], $matches );
|
||||
@@ -43,7 +62,5 @@ class _PHP extends \IPS\Email
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( $e->getMessage() );
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief SendGrid Email Class
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 17 Oct 2013
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\Email\Outgoing;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* SendGrid Email Class
|
||||
*/
|
||||
class _SendGrid extends \IPS\Email
|
||||
{
|
||||
/* !Configuration */
|
||||
|
||||
/**
|
||||
* @brief The number of emails that can be sent in one "go"
|
||||
*/
|
||||
const MAX_EMAILS_PER_GO = 1000; // SendGrid has a hard 1000 recipients per request limit
|
||||
|
||||
/**
|
||||
* @brief If sending a bulk email to more than MAX_EMAILS_PER_GO - does this
|
||||
* class require waiting between cycles? For "standard" classes like
|
||||
* PHP and SMTP, this will be TRUE - and will cause bulk mails to go
|
||||
* to a class. For APIs like SparkPost, this can be FALSE
|
||||
*/
|
||||
const REQUIRES_TIME_BREAK = FALSE;
|
||||
|
||||
/**
|
||||
* @brief API Key
|
||||
*/
|
||||
protected $apiKey;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $apiKey API Key
|
||||
* @return void
|
||||
*/
|
||||
public function __construct( $apiKey )
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email
|
||||
*
|
||||
* @param mixed $to The member or email address, or array of members or email addresses, to send to
|
||||
* @param mixed $cc Addresses to CC (can also be email, member or array of either)
|
||||
* @param mixed $bcc Addresses to BCC (can also be email, member or array of either)
|
||||
* @param mixed $fromEmail The email address to send from. If NULL, default setting is used
|
||||
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
|
||||
* @param array $additionalHeaders The name the email should appear from. If NULL, default setting is used
|
||||
* @return void
|
||||
* @throws \IPS\Email\Outgoing\Exception
|
||||
*/
|
||||
public function _send( $to, $cc=array(), $bcc=array(), $fromEmail = NULL, $fromName = NULL, $additionalHeaders = array() )
|
||||
{
|
||||
/* Initiate the request */
|
||||
$request = $this->_initRequest( $fromEmail, $fromName );
|
||||
|
||||
/* Add the subject and content */
|
||||
$request['subject'] = $this->compileSubject( static::_getMemberFromRecipients( $to ) );
|
||||
$request['content'] = array(
|
||||
array(
|
||||
'type' => 'text/plain',
|
||||
'value' => $this->compileContent( 'plaintext', static::_getMemberFromRecipients( $to ) )
|
||||
),
|
||||
array(
|
||||
'type' => 'text/html',
|
||||
'value' => $this->compileContent( 'html', static::_getMemberFromRecipients( $to ) )
|
||||
),
|
||||
);
|
||||
|
||||
/* Add the recipients */
|
||||
foreach ( array( 'to', 'cc', 'bcc' ) as $type )
|
||||
{
|
||||
if ( is_array( $$type ) )
|
||||
{
|
||||
foreach ( $$type as $recipient )
|
||||
{
|
||||
if ( $recipient instanceof \IPS\Member )
|
||||
{
|
||||
$request['personalizations'][0][ $type ][] = array( 'email' => $recipient->email, 'name' => $recipient->name );
|
||||
}
|
||||
else
|
||||
{
|
||||
$request['personalizations'][0][ $type ][] = array( 'email' => $recipient );
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ( $$type )
|
||||
{
|
||||
$recipient = $$type;
|
||||
if ( $recipient instanceof \IPS\Member )
|
||||
{
|
||||
$request['personalizations'][0][ $type ][] = array( 'email' => $recipient->email, 'name' => $recipient->name );
|
||||
}
|
||||
else
|
||||
{
|
||||
$request['personalizations'][0][ $type ][] = array( 'email' => $recipient );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Add additional headers */
|
||||
$request = $this->_modifyRequestDataWithHeaders( $request, $additionalHeaders );
|
||||
|
||||
/* Send */
|
||||
$response = $this->_api( 'mail/send', $request );
|
||||
if ( isset( $response['errors'] ) )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( $response['errors'][0]['message'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge and Send
|
||||
*
|
||||
* @param array $recipients Array where the keys are the email addresses to send to and the values are an array of variables to replace
|
||||
* @param mixed $fromEmail The email address to send from. If NULL, default setting is used. NOTE: This should always be a site-controlled domin. Some services like Sparkpost require the domain to be validated.
|
||||
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
|
||||
* @param array $additionalHeaders Additional headers to send. Merge tags can be used like in content.
|
||||
* @param \IPS|Lang $language The language the email content should be in
|
||||
* @return int Number of successful sends
|
||||
*/
|
||||
public function mergeAndSend( $recipients, $fromEmail = NULL, $fromName = NULL, $additionalHeaders = array(), \IPS\Lang $language )
|
||||
{
|
||||
/* Initiate the request */
|
||||
$request = $this->_initRequest( $fromEmail, $fromName );
|
||||
|
||||
/* Add the subject and content */
|
||||
$subject = $this->compileSubject( NULL, $language );
|
||||
$htmlContent = $this->compileContent( 'html', FALSE, $language );
|
||||
$plaintextContent = $this->compileContent( 'plaintext', FALSE, $language );
|
||||
$request['subject'] = $subject;
|
||||
$request['content'] = array(
|
||||
array(
|
||||
'type' => 'text/plain',
|
||||
'value' => $plaintextContent
|
||||
),
|
||||
array(
|
||||
'type' => 'text/html',
|
||||
'value' => $htmlContent
|
||||
),
|
||||
);
|
||||
|
||||
/* Add the recipients */
|
||||
$addresses = array();
|
||||
foreach ( $recipients as $email => $substitutions )
|
||||
{
|
||||
$addresses[] = $email;
|
||||
|
||||
$finalSubstitutions = array();
|
||||
foreach ( $substitutions as $k => $v )
|
||||
{
|
||||
$language->parseEmail( $v );
|
||||
$finalSubstitutions["*|{$k}|*"] = $v;
|
||||
}
|
||||
|
||||
$request['personalizations'][] = array(
|
||||
'to' => array( array( 'email' => $email ) ),
|
||||
'substitutions' => $finalSubstitutions
|
||||
);
|
||||
}
|
||||
|
||||
/* Add additional headers */
|
||||
$request = $this->_modifyRequestDataWithHeaders( $request, $additionalHeaders );
|
||||
|
||||
/* Send */
|
||||
try
|
||||
{
|
||||
$response = $this->_api( 'mail/send', $request );
|
||||
}
|
||||
catch( \IPS\Email\Outgoing\Exception $e )
|
||||
{
|
||||
\IPS\Db::i()->insert( 'core_mail_error_logs', array(
|
||||
'mlog_date' => time(),
|
||||
'mlog_to' => json_encode( $addresses ),
|
||||
'mlog_from' => $fromEmail ?: \IPS\Settings::i()->email_out,
|
||||
'mlog_subject' => $subject,
|
||||
'mlog_content' => $htmlContent,
|
||||
'mlog_resend_data' => NULL,
|
||||
'mlog_msg' => json_encode( $e->getMessage() ),
|
||||
'mlog_smtp_log' => NULL
|
||||
) );
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$errorcount = 0;
|
||||
if ( isset( $response['errors'] ) )
|
||||
{
|
||||
$errorcount = count( $response['errors'] );
|
||||
\IPS\Db::i()->insert( 'core_mail_error_logs', array(
|
||||
'mlog_date' => time(),
|
||||
'mlog_to' => json_encode( $addresses ),
|
||||
'mlog_from' => $fromEmail ?: \IPS\Settings::i()->email_out,
|
||||
'mlog_subject' => $subject,
|
||||
'mlog_content' => $htmlContent,
|
||||
'mlog_resend_data' => NULL,
|
||||
'mlog_msg' => json_encode( $response['errors'] ),
|
||||
'mlog_smtp_log' => NULL
|
||||
) );
|
||||
}
|
||||
return ( count( $recipients ) - $errorcount );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a request
|
||||
*
|
||||
* @param array $recipients Array where the keys are the email addresses to send to and the values are an array of variables to replace
|
||||
* @param mixed $fromEmail The email address to send from. If NULL, default setting is used. NOTE: This should always be a site-controlled domin. Some services like Sparkpost require the domain to be validated.
|
||||
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
|
||||
* @param array $additionalHeaders Additional headers to send. Merge tags can be used like in content.
|
||||
* @param \IPS|Lang $language The language the email content should be in
|
||||
* @return array
|
||||
*/
|
||||
protected function _initRequest( $fromEmail = NULL, $fromName = NULL )
|
||||
{
|
||||
$request = array(
|
||||
'personalizations' => array(),
|
||||
'from' => array(
|
||||
'email' => $fromEmail ?: \IPS\Settings::i()->email_out,
|
||||
'name' => $fromName ?: \IPS\Settings::i()->board_name
|
||||
),
|
||||
'tracking_settings' => array(
|
||||
'click_tracking' => array(
|
||||
'enable' => (bool) \IPS\Settings::i()->sendgrid_click_tracking,
|
||||
'enable_text' => (bool) \IPS\Settings::i()->sendgrid_click_tracking,
|
||||
),
|
||||
'open_tracking' => array(
|
||||
'enable' => (bool) \IPS\Settings::i()->sendgrid_click_tracking,
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if ( \IPS\Settings::i()->sendgrid_ip_pool )
|
||||
{
|
||||
$request['ip_pool_name'] = (bool) \IPS\Settings::i()->sendgrid_ip_pool;
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify the request data that will be sent to the SparkPost API with header data
|
||||
*
|
||||
* @param array $request SparkPost API request data
|
||||
* @param array $additionalHeaders Additional headers to send
|
||||
* @param array $allowedTags The tags that we want to parse
|
||||
* @return array
|
||||
*/
|
||||
protected function _modifyRequestDataWithHeaders( $request, $additionalHeaders = array(), $allowedTags = array() )
|
||||
{
|
||||
/* Do we have a Reply-To? */
|
||||
if ( isset( $additionalHeaders['Reply-To'] ) )
|
||||
{
|
||||
if ( preg_match( '/(.*)\s?<(.*)>$/', $additionalHeaders['Reply-To'], $matches ) )
|
||||
{
|
||||
$email = $matches[2];
|
||||
|
||||
$request['reply_to'] = array( 'email' => $matches[2] );
|
||||
|
||||
if ( $matches[1] )
|
||||
{
|
||||
if ( preg_match( '/^\=\?UTF\-8\?B\?(.+?)\?\=$/i', trim( $matches[1] ), $_matches ) )
|
||||
{
|
||||
$request['reply_to']['name'] = base64_decode( $_matches[1] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unset( $additionalHeaders['Reply-To'] );
|
||||
}
|
||||
|
||||
/* Any other headers? */
|
||||
unset( $additionalHeaders['x-sg-id'] );
|
||||
unset( $additionalHeaders['x-sg-eid'] );
|
||||
unset( $additionalHeaders['received'] );
|
||||
unset( $additionalHeaders['dkim-signature'] );
|
||||
unset( $additionalHeaders['Content-Type'] );
|
||||
unset( $additionalHeaders['Content-Transfer-Encoding'] );
|
||||
unset( $additionalHeaders['Subject'] );
|
||||
unset( $additionalHeaders['From'] );
|
||||
unset( $additionalHeaders['To'] );
|
||||
unset( $additionalHeaders['CC'] );
|
||||
unset( $additionalHeaders['BCC'] );
|
||||
if ( count( $additionalHeaders ) )
|
||||
{
|
||||
$request['headers'] = $additionalHeaders;
|
||||
}
|
||||
|
||||
/* Return */
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API call
|
||||
*
|
||||
* @param string $method Method
|
||||
* @param string $apiKey API Key
|
||||
* @param array $args Arguments
|
||||
* @throws \IPS\Email\Outgoing\Exception Indicates an invalid JSON response or HTTP error
|
||||
* @return array|null
|
||||
*/
|
||||
protected function _api( $method, $args=NULL )
|
||||
{
|
||||
$request = \IPS\Http\Url::external( 'https://api.sendgrid.com/v3/' . $method )
|
||||
->request( \IPS\LONG_REQUEST_TIMEOUT )
|
||||
->setHeaders( array( 'Content-Type' => 'application/json', 'Authorization' => "Bearer {$this->apiKey}" ) );
|
||||
|
||||
try
|
||||
{
|
||||
if ( $args )
|
||||
{
|
||||
$response = $request->post( json_encode( $args ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
$response = $request->get();
|
||||
}
|
||||
|
||||
|
||||
if ( $response->content )
|
||||
{
|
||||
$response = $response->decodeJson();
|
||||
}
|
||||
else
|
||||
{
|
||||
$response = null;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
catch ( \IPS\Http\Request\Exception $e )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( $e->getMessage(), $e->getCode() );
|
||||
}
|
||||
/* Capture json decoding errors */
|
||||
catch ( \RuntimeException $e )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( $e->getMessage(), $e->getCode() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key scopes
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function scopes()
|
||||
{
|
||||
return $this->_api( 'scopes' );
|
||||
}
|
||||
|
||||
}
|
||||
+130
-60
@@ -2,9 +2,9 @@
|
||||
/**
|
||||
* @brief SMTP Email Class
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc.
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Social Suite
|
||||
* @package IPS Community Suite
|
||||
* @since 17 Apr 2013
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
@@ -24,41 +24,60 @@ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
||||
class _SMTP extends \IPS\Email
|
||||
{
|
||||
/**
|
||||
* @brief SMTP Conversation Log
|
||||
* @brief SMTP Protocol ("tls", "ssl" or "plain")
|
||||
*/
|
||||
public $log;
|
||||
protected $smtpProtocol;
|
||||
|
||||
/**
|
||||
* @brief SMTP Host
|
||||
*/
|
||||
protected $smtpHost;
|
||||
|
||||
/**
|
||||
* @brief SMTP Port
|
||||
*/
|
||||
protected $smtpPort;
|
||||
|
||||
/**
|
||||
* @brief SMTP Username
|
||||
*/
|
||||
protected $smtpUser;
|
||||
|
||||
/**
|
||||
* @brief SMTP Password
|
||||
*/
|
||||
protected $smtpPass;
|
||||
|
||||
/**
|
||||
* @brief SMTP Connections
|
||||
*/
|
||||
protected static $smtp = array();
|
||||
|
||||
/**
|
||||
* @brief Connection Key
|
||||
*/
|
||||
protected $connectionKey;
|
||||
|
||||
/**
|
||||
* @brief Log
|
||||
*/
|
||||
protected $log = '';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
public function __construct( $smtpProtocol, $smtpHost, $smtpPort, $smtpUser, $smtpPass )
|
||||
{
|
||||
$this->log = '';
|
||||
$this->smtpProtocol = $smtpProtocol;
|
||||
$this->smtpHost = $smtpHost;
|
||||
$this->smtpPort = $smtpPort;
|
||||
$this->smtpUser = $smtpUser;
|
||||
$this->smtpPass = $smtpPass;
|
||||
$this->connectionKey = md5( $smtpProtocol . $smtpHost . $smtpPort . $smtpUser . $smtpPass );
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
if( static::$smtp !== NULL )
|
||||
{
|
||||
$this->_sendCommand( 'quit' );
|
||||
|
||||
@fclose( static::$smtp );
|
||||
static::$smtp = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief SMTP Connection
|
||||
*/
|
||||
protected static $smtp = NULL;
|
||||
|
||||
/**
|
||||
* Connect to server
|
||||
*
|
||||
@@ -67,25 +86,27 @@ class _SMTP extends \IPS\Email
|
||||
public function connect()
|
||||
{
|
||||
/* Do we already have a connection? */
|
||||
if( static::$smtp !== NULL )
|
||||
if( array_key_exists( $this->connectionKey, static::$smtp ) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
/* Connect */
|
||||
static::$smtp = @fsockopen( ( ( \IPS\Settings::i()->smtp_protocol == 'ssl' ) ? 'ssl://' : '' ) . \IPS\Settings::i()->smtp_host, \IPS\Settings::i()->smtp_port, $errno, $errstr );
|
||||
|
||||
if ( !static::$smtp )
|
||||
$connection = @fsockopen( ( ( $this->smtpProtocol == 'ssl' ) ? 'ssl://' : '' ) . $this->smtpHost, $this->smtpPort, $errno, $errstr );
|
||||
if ( !$connection )
|
||||
{
|
||||
static::$smtp = NULL;
|
||||
throw new \IPS\Email\Outgoing\Exception( $errstr, $errno );
|
||||
}
|
||||
static::$smtp[ $this->connectionKey ] = $connection;
|
||||
register_shutdown_function(function( $object ){
|
||||
$object->_sendCommand( 'quit' );
|
||||
@fclose( static::$smtp[ $object->connectionKey ] );
|
||||
unset( static::$smtp[ $object->connectionKey ] );
|
||||
}, $this );
|
||||
|
||||
/* Check the initial response is okay */
|
||||
$announce = $this->_getResponse();
|
||||
$this->log .= $announce;
|
||||
$responseCode = mb_substr( $announce, 0, 3 );
|
||||
|
||||
if ( $responseCode != 220 )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( \IPS\Member::loggedIn()->language()->addToStack('smtpmail_fsock_error_initial', FALSE, array( 'sprintf' => array( $responseCode ) ) ) );
|
||||
@@ -95,79 +116,124 @@ class _SMTP extends \IPS\Email
|
||||
try
|
||||
{
|
||||
$helo = 'EHLO';
|
||||
$responseCode = $this->_sendCommand( 'EHLO ' . \IPS\Settings::i()->smtp_host, 250 );
|
||||
$responseCode = $this->_sendCommand( 'EHLO ' . $this->smtpHost, 250 );
|
||||
}
|
||||
catch ( \IPS\Email\Outgoing\Exception $e )
|
||||
{
|
||||
$helo = 'HELO';
|
||||
$responseCode = $this->_sendCommand( 'HELO ' . \IPS\Settings::i()->smtp_host, 250 );
|
||||
$responseCode = $this->_sendCommand( 'HELO ' . $this->smtpHost, 250 );
|
||||
}
|
||||
|
||||
/* Is TLS being used? */
|
||||
if( \IPS\Settings::i()->smtp_protocol == 'tls' )
|
||||
if( $this->smtpProtocol == 'tls' )
|
||||
{
|
||||
$this->_sendCommand( 'STARTTLS', 220 );
|
||||
|
||||
if ( !@\stream_socket_enable_crypto( static::$smtp, TRUE, STREAM_CRYPTO_METHOD_TLS_CLIENT ) )
|
||||
if ( !@\stream_socket_enable_crypto( static::$smtp[ $this->connectionKey ], TRUE, version_compare( PHP_VERSION, '5.6.7' ) >= 0 ? STREAM_CRYPTO_METHOD_SSLv23_CLIENT : STREAM_CRYPTO_METHOD_TLS_CLIENT ) )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( \IPS\Member::loggedIn()->language()->addToStack('smtpmail_tls_failed') );
|
||||
}
|
||||
|
||||
/* Exchange server (at least) wants EHLO resending for STARTTLS */
|
||||
$this->_sendCommand( $helo . ' ' . \IPS\Settings::i()->smtp_host, 250 );
|
||||
$this->_sendCommand( $helo . ' ' . $this->smtpHost, 250 );
|
||||
}
|
||||
|
||||
/* Authenticate */
|
||||
if ( \IPS\Settings::i()->smtp_user )
|
||||
if ( $this->smtpUser )
|
||||
{
|
||||
$responseCode = $this->_sendCommand( 'AUTH LOGIN', 334 );
|
||||
$responseCode = $this->_sendCommand( base64_encode( \IPS\Settings::i()->smtp_user ), 334 );
|
||||
$responseCode = $this->_sendCommand( base64_encode( \IPS\Settings::i()->smtp_pass ), 235 );
|
||||
$responseCode = $this->_sendCommand( base64_encode( $this->smtpUser ), 334 );
|
||||
$responseCode = $this->_sendCommand( base64_encode( $this->smtpPass ), 235 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send
|
||||
*
|
||||
* @throws \Exception
|
||||
* @note We use $this->from here instead of the 'From' header, because the header may have a display name embedded
|
||||
* Send the email
|
||||
*
|
||||
* @param mixed $to The member or email address, or array of members or email addresses, to send to
|
||||
* @param mixed $cc Addresses to CC (can also be email, member or array of either)
|
||||
* @param mixed $bcc Addresses to BCC (can also be email, member or array of either)
|
||||
* @param mixed $fromEmail The email address to send from. If NULL, default setting is used
|
||||
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
|
||||
* @param array $additionalHeaders Additional headers to send
|
||||
* @return void
|
||||
* @throws \IPS\Email\Outgoing\Exception
|
||||
*/
|
||||
protected function _send()
|
||||
public function _send( $to, $cc=array(), $bcc=array(), $fromEmail = NULL, $fromName = NULL, $additionalHeaders = array() )
|
||||
{
|
||||
/* Get the from email */
|
||||
$fromEmail = $fromEmail ?: \IPS\Settings::i()->email_out;
|
||||
|
||||
/* SMTP requires you to do CC/BCC by sending a RCPT TO command for each recipient. We'll hide BCC by not actually setting that header */
|
||||
$recipieintsForSmtp = explode( ',', static::_parseRecipients( $to, TRUE ) );
|
||||
if ( $cc )
|
||||
{
|
||||
$recipieintsForSmtp = array_merge( $recipieintsForSmtp, explode( ',', static::_parseRecipients( $cc, TRUE ) ) );
|
||||
}
|
||||
if ( $bcc )
|
||||
{
|
||||
$recipieintsForSmtp = array_merge( $recipieintsForSmtp, explode( ',', static::_parseRecipients( $bcc, TRUE ) ) );
|
||||
}
|
||||
$recipieintsForSmtp = array_unique( array_map( 'trim', $recipieintsForSmtp ) );
|
||||
|
||||
/* Send */
|
||||
$this->_sendCompiled( $fromEmail, $recipieintsForSmtp, $this->compileFullEmail( $to, $cc, array(), $fromEmail, $fromName, $additionalHeaders ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email
|
||||
*
|
||||
* @param string $fromEmail The email address to send from
|
||||
* @param array $toEmails Array of email addresses to send to
|
||||
* @param string $email The full email (with headers, etc.) except the Bcc header
|
||||
* @param array $additionalHeaders Additional headers to send
|
||||
* @return void
|
||||
* @throws \IPS\Email\Outgoing\Exception
|
||||
*/
|
||||
public function _sendCompiled( $fromEmail, $toEmails, $email )
|
||||
{
|
||||
$this->connect();
|
||||
$this->_sendCommand( "MAIL FROM:<{$this->from}>", 250 );
|
||||
|
||||
foreach ( $this->to as $to )
|
||||
$this->_sendCommand( "MAIL FROM:<{$fromEmail}>", 250 );
|
||||
|
||||
foreach ( $toEmails as $toEmail )
|
||||
{
|
||||
$this->_sendCommand( "RCPT TO:<{$to}>", 250 );
|
||||
$this->_sendCommand( "RCPT TO:<{$toEmail}>", 250 );
|
||||
}
|
||||
|
||||
$this->_sendCommand( 'DATA', 354 );
|
||||
$this->_sendCommand( "{$this->_compileHeaders()}\r\n\r\n{$this->message}\r\n.", 250 );
|
||||
$this->_sendCommand( $email . "\r\n.", 250 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP Command
|
||||
*
|
||||
* @param string $command The command
|
||||
* @param int $expectedResponse The expected response code. Will throw an exception if different.
|
||||
* @param string $command The command
|
||||
* @param int|NULL $expectedResponse The expected response code. Will throw an exception if different.
|
||||
* @param bool $resetOnFailure If the command fails, issue a RSET (reset) command
|
||||
* @return string Response
|
||||
* @throws \IPS\Email\Outgoing\Exception
|
||||
*/
|
||||
protected function _sendCommand( $command, $expectedResponse=NULL )
|
||||
protected function _sendCommand( $command, $expectedResponse=NULL, $resetOnFailure=FALSE )
|
||||
{
|
||||
/* Send */
|
||||
/* Log */
|
||||
$this->log .= "> {$command}\r\n";
|
||||
fputs( static::$smtp, $command . "\r\n" );
|
||||
|
||||
/* Send */
|
||||
fputs( static::$smtp[ $this->connectionKey ], $command . "\r\n" );
|
||||
|
||||
/* Read */
|
||||
$response = $this->_getResponse();
|
||||
$this->log .= $response;
|
||||
|
||||
/* Get response code */
|
||||
$code = intval( mb_substr( $response, 0, 3 ) );
|
||||
if ( $expectedResponse !== NULL and $code !== $expectedResponse )
|
||||
{
|
||||
if( $resetOnFailure === TRUE )
|
||||
{
|
||||
$this->_sendCommand( 'RSET' );
|
||||
}
|
||||
|
||||
throw new \IPS\Email\Outgoing\Exception( $response, $code );
|
||||
}
|
||||
|
||||
@@ -184,7 +250,7 @@ class _SMTP extends \IPS\Email
|
||||
{
|
||||
/* Read */
|
||||
$response = '';
|
||||
while ( $line = @fgets( static::$smtp, 515 ) )
|
||||
while ( $line = @fgets( static::$smtp[ $this->connectionKey ], 515 ) )
|
||||
{
|
||||
$response .= $line;
|
||||
if ( mb_substr($line, 3, 1) == " " )
|
||||
@@ -192,10 +258,14 @@ class _SMTP extends \IPS\Email
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Log */
|
||||
$this->log .= $response;
|
||||
|
||||
/* Return */
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return the SMTP log
|
||||
*
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief SparkPost Email Class
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - 2016 Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Community Suite
|
||||
* @since 17 Apr 2013
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\Email\Outgoing;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* SparkPost Email Class
|
||||
*/
|
||||
class _SparkPost extends \IPS\Email
|
||||
{
|
||||
/* !Configuration */
|
||||
|
||||
/**
|
||||
* @brief The number of emails that can be sent in one "go"
|
||||
*/
|
||||
const MAX_EMAILS_PER_GO = 2000; // SparkPost "recommends" 2000 per cycle
|
||||
|
||||
/**
|
||||
* @brief If sending a bulk email to more than MAX_EMAILS_PER_GO - does this
|
||||
* class require waiting between cycles? For "standard" classes like
|
||||
* PHP and SMTP, this will be TRUE - and will cause bulk mails to go
|
||||
* to a class. For APIs like SparkPost, this can be FALSE
|
||||
*/
|
||||
const REQUIRES_TIME_BREAK = FALSE;
|
||||
|
||||
/**
|
||||
* @brief API Key
|
||||
*/
|
||||
protected $apiKey;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $apiKey API Key
|
||||
* @return void
|
||||
*/
|
||||
public function __construct( $apiKey )
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email
|
||||
*
|
||||
* @param mixed $to The member or email address, or array of members or email addresses, to send to
|
||||
* @param mixed $cc Addresses to CC (can also be email, member or array of either)
|
||||
* @param mixed $bcc Addresses to BCC (can also be email, member or array of either)
|
||||
* @param mixed $fromEmail The email address to send from. If NULL, default setting is used
|
||||
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
|
||||
* @param array $additionalHeaders The name the email should appear from. If NULL, default setting is used
|
||||
* @return void
|
||||
* @throws \IPS\Email\Outgoing\Exception
|
||||
*/
|
||||
public function _send( $to, $cc=array(), $bcc=array(), $fromEmail = NULL, $fromName = NULL, $additionalHeaders = array() )
|
||||
{
|
||||
/* Compile the recipients */
|
||||
$recipients = array();
|
||||
$mainToAddress = NULL;
|
||||
foreach ( array_map( 'trim', explode( ',', static::_parseRecipients( $to, TRUE ) ) ) as $address )
|
||||
{
|
||||
if ( $mainToAddress === NULL )
|
||||
{
|
||||
$mainToAddress = $address;
|
||||
}
|
||||
$recipients[] = array( 'address' => array( 'email' => $address ) );
|
||||
}
|
||||
if ( $cc )
|
||||
{
|
||||
foreach ( array_map( 'trim', explode( ',', static::_parseRecipients( $cc, TRUE ) ) ) as $address )
|
||||
{
|
||||
$recipients[] = array( 'address' => array( 'email' => $address, 'header_to' => $mainToAddress ) );
|
||||
}
|
||||
$additionalHeaders['Cc'] = static::_parseRecipients( $cc );
|
||||
}
|
||||
if ( $bcc )
|
||||
{
|
||||
foreach ( array_map( 'trim', explode( ',', static::_parseRecipients( $bcc, TRUE ) ) ) as $address )
|
||||
{
|
||||
$recipients[] = array( 'address' => array( 'email' => $address, 'header_to' => $mainToAddress ) );
|
||||
}
|
||||
}
|
||||
|
||||
/* Compile the API call request data */
|
||||
$request = array(
|
||||
'recipients' => $recipients,
|
||||
'content' => array(
|
||||
'html' => static::_escapeTemplateTags( $this->compileContent( 'html', static::_getMemberFromRecipients( $to ) ) ),
|
||||
'text' => static::_escapeTemplateTags( $this->compileContent( 'plaintext', static::_getMemberFromRecipients( $to ) ) ),
|
||||
'subject' => static::_escapeTemplateTags( $this->compileSubject( static::_getMemberFromRecipients( $to ) ) ),
|
||||
'from' => array(
|
||||
'email' => $fromEmail ?: \IPS\Settings::i()->email_out,
|
||||
'name' => $fromName ?: \IPS\Settings::i()->board_name
|
||||
)
|
||||
),
|
||||
'options' => array(
|
||||
'transactional' => $this->type === static::TYPE_TRANSACTIONAL,
|
||||
'open_tracking' => (bool) \IPS\Settings::i()->sparkpost_click_tracking,
|
||||
'click_tracking' => (bool) \IPS\Settings::i()->sparkpost_click_tracking
|
||||
)
|
||||
);
|
||||
if ( \IPS\Settings::i()->sparkpost_ip_pool )
|
||||
{
|
||||
$request['options']['ip_pool'] = \IPS\Settings::i()->sparkpost_ip_pool;
|
||||
}
|
||||
$request = $this->_modifyRequestDataWithHeaders( $request, $additionalHeaders );
|
||||
|
||||
/* Make API call */
|
||||
$response = $this->_api( 'transmissions', $request );
|
||||
if ( isset( $response['errors'] ) )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( $response['errors'][0]['message'], ( isset( $response['errors'][0]['code'] ) ) ? $response['errors'][0]['code'] : NULL );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify the request data that will be sent to the SparkPost API with header data
|
||||
*
|
||||
* @param array $request SparkPost API request data
|
||||
* @param array $additionalHeaders Additional headers to send
|
||||
* @param array $allowedTags The tags that we want to parse
|
||||
* @return array
|
||||
*/
|
||||
protected function _modifyRequestDataWithHeaders( $request, $additionalHeaders = array(), $allowedTags = array() )
|
||||
{
|
||||
/* Do we have a Reply-To? */
|
||||
if ( isset( $additionalHeaders['Reply-To'] ) )
|
||||
{
|
||||
$request['content']['reply_to'] = static::_escapeTemplateTags( $additionalHeaders['Reply-To'], $allowedTags );
|
||||
unset( $additionalHeaders['Reply-To'] );
|
||||
}
|
||||
|
||||
/* Any other headers? */
|
||||
unset( $additionalHeaders['Subject'] );
|
||||
unset( $additionalHeaders['From'] );
|
||||
unset( $additionalHeaders['To'] );
|
||||
if ( count( $additionalHeaders ) )
|
||||
{
|
||||
$request['content']['headers'] = array_map( function( $v ) use ( $allowedTags ) {
|
||||
return static::_escapeTemplateTags( $v, $allowedTags );
|
||||
}, $additionalHeaders );
|
||||
}
|
||||
|
||||
/* Return */
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge and Send
|
||||
*
|
||||
* @param array $recipients Array where the keys are the email addresses to send to and the values are an array of variables to replace
|
||||
* @param mixed $fromEmail The email address to send from. If NULL, default setting is used. NOTE: This should always be a site-controlled domin. Some services like Sparkpost require the domain to be validated.
|
||||
* @param mixed $fromName The name the email should appear from. If NULL, default setting is used
|
||||
* @param array $additionalHeaders Additional headers to send. Merge tags can be used like in content.
|
||||
* @param \IPS|Lang $language The language the email content should be in
|
||||
* @return int Number of successful sends
|
||||
*/
|
||||
public function mergeAndSend( $recipients, $fromEmail = NULL, $fromName = NULL, $additionalHeaders = array(), \IPS\Lang $language )
|
||||
{
|
||||
/* Work out recipients */
|
||||
$varNames = array();
|
||||
$recipientsForSparkpost = array();
|
||||
$addresses = array();
|
||||
foreach ( $recipients as $address => $_vars )
|
||||
{
|
||||
$addresses[] = $address;
|
||||
$vars = array();
|
||||
|
||||
foreach ( $_vars as $k => $v )
|
||||
{
|
||||
$language->parseEmail( $v );
|
||||
|
||||
$vars[ $k ] = $v;
|
||||
|
||||
if ( !in_array( $k, $varNames ) )
|
||||
{
|
||||
$varNames[] = $k;
|
||||
}
|
||||
}
|
||||
|
||||
$recipientsForSparkpost[] = array( 'address' => array( 'email' => $address ), 'substitution_data' => $vars );
|
||||
}
|
||||
|
||||
/* Put tags into SparkPost format */
|
||||
$htmlContent = str_replace( array( '*|', '|*' ), array( '{{{', '}}}' ), $this->compileContent( 'html', FALSE, $language ) );
|
||||
$plaintextContent = str_replace( array( '*|', '|*' ), array( '{{{', '}}}' ), $this->compileContent( 'plaintext', FALSE, $language ) );
|
||||
$subject = str_replace( array( '*|', '|*' ), array( '{{{', '}}}' ), $this->compileSubject( NULL, $language ) );
|
||||
$_additionalHeaders = array();
|
||||
foreach ( $additionalHeaders as $k => $v )
|
||||
{
|
||||
$_additionalHeaders[ $k ] = str_replace( array( '*|', '|*' ), array( '{{{', '}}}' ), $v );
|
||||
}
|
||||
|
||||
/* Compile the API call request data */
|
||||
$request = array(
|
||||
'recipients' => $recipientsForSparkpost,
|
||||
'content' => array(
|
||||
'html' => static::_escapeTemplateTags( $htmlContent, $varNames ),
|
||||
'text' => static::_escapeTemplateTags( $plaintextContent, $varNames ),
|
||||
'subject' => static::_escapeTemplateTags( $subject, $varNames ),
|
||||
'from' => array(
|
||||
'email' => $fromEmail ?: \IPS\Settings::i()->email_out,
|
||||
'name' => $fromName ?: \IPS\Settings::i()->board_name
|
||||
)
|
||||
),
|
||||
'options' => array(
|
||||
'transactional' => $this->type === static::TYPE_TRANSACTIONAL,
|
||||
'open_tracking' => (bool) \IPS\Settings::i()->sparkpost_click_tracking,
|
||||
'click_tracking' => (bool) \IPS\Settings::i()->sparkpost_click_tracking
|
||||
)
|
||||
);
|
||||
if ( \IPS\Settings::i()->sparkpost_ip_pool )
|
||||
{
|
||||
$request['options']['ip_pool'] = \IPS\Settings::i()->sparkpost_ip_pool;
|
||||
}
|
||||
$request = $this->_modifyRequestDataWithHeaders( $request, $_additionalHeaders, $varNames );
|
||||
|
||||
/* Make API call */
|
||||
try
|
||||
{
|
||||
$response = $this->_api( 'transmissions', $request );
|
||||
}
|
||||
catch( \IPS\Email\Outgoing\Exception $e )
|
||||
{
|
||||
\IPS\Db::i()->insert( 'core_mail_error_logs', array(
|
||||
'mlog_date' => time(),
|
||||
'mlog_to' => json_encode( $addresses ),
|
||||
'mlog_from' => $fromEmail ?: \IPS\Settings::i()->email_out,
|
||||
'mlog_subject' => $subject,
|
||||
'mlog_content' => $htmlContent,
|
||||
'mlog_resend_data' => NULL,
|
||||
'mlog_msg' => json_encode( $e->getMessage() ),
|
||||
'mlog_smtp_log' => NULL
|
||||
) );
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ( isset( $response['errors'] ) )
|
||||
{
|
||||
\IPS\Db::i()->insert( 'core_mail_error_logs', array(
|
||||
'mlog_date' => time(),
|
||||
'mlog_to' => json_encode( $addresses ),
|
||||
'mlog_from' => $fromEmail ?: \IPS\Settings::i()->email_out,
|
||||
'mlog_subject' => $subject,
|
||||
'mlog_content' => $htmlContent,
|
||||
'mlog_resend_data' => NULL,
|
||||
'mlog_msg' => json_encode( $response['errors'] ),
|
||||
'mlog_smtp_log' => NULL
|
||||
) );
|
||||
}
|
||||
|
||||
return ( isset( $response['results'] ) and isset( $response['results']['total_accepted_recipients'] ) ) ? $response['results']['total_accepted_recipients'] : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API call
|
||||
*
|
||||
* @param string $method Method
|
||||
* @param string $apiKey API Key
|
||||
* @param array $args Arguments
|
||||
* @throws \IPS\Email\Outgoing\Exception Indicates an invalid JSON response or HTTP error
|
||||
* @return array
|
||||
*/
|
||||
protected function _api( $method, $args=NULL )
|
||||
{
|
||||
$request = \IPS\Http\Url::external( 'https://api.sparkpost.com/api/v1/' . $method )
|
||||
->request( \IPS\LONG_REQUEST_TIMEOUT )
|
||||
->setHeaders( array( 'Content-Type' => 'application/json', 'Authorization' => $this->apiKey ) );
|
||||
|
||||
try
|
||||
{
|
||||
if ( $args )
|
||||
{
|
||||
$response = $request->post( json_encode( $args ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
$response = $request->get();
|
||||
}
|
||||
|
||||
return $response->decodeJson();
|
||||
}
|
||||
catch ( \IPS\Http\Request\Exception $e )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( $e->getMessage(), $e->getCode() );
|
||||
}
|
||||
/* Capture json decoding errors */
|
||||
catch ( \RuntimeException $e )
|
||||
{
|
||||
throw new \IPS\Email\Outgoing\Exception( $e->getMessage(), $e->getCode() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape template tags
|
||||
*
|
||||
* @param string $content The content
|
||||
* @param array $allowedTags The tags that we want to parse
|
||||
* @return array
|
||||
* @see <a href="https://developers.sparkpost.com/api/#/introduction/substitutions-reference/escaping-start-and-end-tags">Escaping Start and End Tags</a>
|
||||
*/
|
||||
protected static function _escapeTemplateTags( $content , $allowedTags = array() )
|
||||
{
|
||||
/* Store the tags we're allowed */
|
||||
$replacedTagsI = 0;
|
||||
$replacedTagsStore = array();
|
||||
$content = preg_replace_callback( '/\{\{\{(' . implode( '|', array_map( function( $val ) { return preg_quote( $val, '/' ); }, $allowedTags ) ) . ')\}\}\}/', function( $matches ) use ( &$replacedTagsI, &$replacedTagsStore )
|
||||
{
|
||||
$replacedTagsStore[ ++$replacedTagsI ] = $matches[0];
|
||||
return '--rt-' . $replacedTagsI . '--';
|
||||
}, $content );
|
||||
|
||||
/* Escape */
|
||||
$content = str_replace( '{{{', '--ot--x--', $content );
|
||||
$content = str_replace( '}}}', '--ct--x--', $content );
|
||||
$content = str_replace( '{{', '--od--x--', $content );
|
||||
$content = str_replace( '}}', '--cd--x--', $content );
|
||||
$content = str_replace( '--ot--x--', '{{opening_triple_curly()}}', $content );
|
||||
$content = str_replace( '--ct--x--', '{{closing_triple_curly()}}', $content );
|
||||
$content = str_replace( '--od--x--', '{{opening_double_curly()}}', $content );
|
||||
$content = str_replace( '--cd--x--', '{{closing_double_curly()}}', $content );
|
||||
|
||||
/* Put our {{{tags}}} back */
|
||||
$content = preg_replace_callback( '/--rt-(.+?)--/', function( $matches ) use ( $replacedTagsStore )
|
||||
{
|
||||
return isset( $replacedTagsStore[ $matches[1] ] ) ? $replacedTagsStore[ $matches[1] ] : '';
|
||||
}, $content );
|
||||
|
||||
/* Return */
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sending domains
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function sendingDomains()
|
||||
{
|
||||
return $this->_api( 'sending-domains' );
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief Magic Template Class for IN_DEV mode
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Social Suite
|
||||
* @since 12 May 2014
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\Email\Theme;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic Template Class for IN_DEV mode
|
||||
*/
|
||||
class _Template extends \IPS\Theme\Template
|
||||
{
|
||||
/**
|
||||
* @brief Source Folder
|
||||
*/
|
||||
public $sourceFolder = NULL;
|
||||
|
||||
/**
|
||||
* Contructor
|
||||
*
|
||||
* @param string $app Application Key
|
||||
* @param string $templateLocation Template location (admin/public/etc.)
|
||||
* @param string $templateName Template Name
|
||||
* @return void
|
||||
*/
|
||||
public function __construct( $app, $templateLocation, $templateName )
|
||||
{
|
||||
parent::__construct( $app, $templateLocation, $templateName );
|
||||
$this->app = $app;
|
||||
$this->templateLocation = $templateLocation;
|
||||
$this->templateName = $templateName;
|
||||
|
||||
if ( \IPS\IN_DEV )
|
||||
{
|
||||
$this->sourceFolder = \IPS\ROOT_PATH . "/applications/{$app}/dev/email/{$templateLocation}/{$templateName}/";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic Method: Call Template Bit
|
||||
*
|
||||
* @param string $bit Template Bit Name
|
||||
* @param array $params Parameters
|
||||
* @return string
|
||||
*/
|
||||
public function __call( $bit, $params )
|
||||
{
|
||||
if ( \IPS\IN_DEV )
|
||||
{
|
||||
/* What are we calling this? */
|
||||
$functionName = "theme_{$this->app}_{$this->templateLocation}_{$this->templateName}_{$bit}";
|
||||
|
||||
/* If it doesn't exist, build it */
|
||||
if( !function_exists( 'IPS\\Theme\\'.$functionName ) )
|
||||
{
|
||||
/* Find the file */
|
||||
$file = $this->sourceFolder . $bit . ( ( $this->templateLocation == 'html' ) ? '.phtml' : '.txt' );
|
||||
|
||||
/* Get the content */
|
||||
if ( $file === NULL or !file_exists( $file ) )
|
||||
{
|
||||
throw new \BadMethodCallException( 'NO_TEMPLATE_FILE - ' . $file );
|
||||
}
|
||||
|
||||
$output = file_get_contents( $file );
|
||||
|
||||
/* Parse the header tag */
|
||||
if ( !preg_match( '/^<ips:template parameters="(.+?)?"([^>]+?)>(\r\n?|\n)/', $output, $matches ) )
|
||||
{
|
||||
throw new \BadMethodCallException( 'NO_HEADER - ' . $file );
|
||||
}
|
||||
|
||||
/* Strip it */
|
||||
$output = preg_replace( '/^<ips:template parameters="(.+?)?"([^>]+?)>(\r\n?|\n)/', '', $output );
|
||||
|
||||
/* Make it into a lovely function */
|
||||
\IPS\Theme::makeProcessFunction( $output, $functionName, ( isset( $matches[1] ) ? $matches[1] : '' ), TRUE );
|
||||
}
|
||||
|
||||
/* Run it */
|
||||
ob_start();
|
||||
$return = call_user_func_array( 'IPS\\Theme\\'.$functionName, $params );
|
||||
if( $error = ob_get_clean() )
|
||||
{
|
||||
echo "<strong>{$functionName}</strong><br>{$error}<br><br><pre>{$output}";
|
||||
exit;
|
||||
}
|
||||
|
||||
/* Return */
|
||||
return $return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @brief IN_DEV Skin Set
|
||||
* @author <a href='http://www.invisionpower.com'>Invision Power Services, Inc.</a>
|
||||
* @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc.
|
||||
* @license http://www.invisionpower.com/legal/standards/
|
||||
* @package IPS Social Suite
|
||||
* @since 12 May 2014
|
||||
* @version SVN_VERSION_NUMBER
|
||||
*/
|
||||
|
||||
namespace IPS\Email\Theme;
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* IN_DEV Skin set
|
||||
*/
|
||||
class _Theme extends \IPS\Theme
|
||||
{
|
||||
/**
|
||||
* @brief Template Classes
|
||||
*/
|
||||
protected $templates;
|
||||
|
||||
/**
|
||||
* @brief [SkinSets] Templates already loaded and evald via getTemplate()
|
||||
*/
|
||||
public static $calledTemplates = array();
|
||||
|
||||
/**
|
||||
* Get currently logged in member's theme
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public static function i()
|
||||
{
|
||||
return new self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write IN_DEV files
|
||||
*
|
||||
* @param boolean $force TRUE to rewrite templates, FALSE to check if exists first
|
||||
* @return void
|
||||
*/
|
||||
public static function writeInDev( $force=FALSE )
|
||||
{
|
||||
$path = \IPS\ROOT_PATH . '/applications/content/dev/html';
|
||||
|
||||
if ( ! \IPS\IN_DEV OR ! is_writeable( $path ) )
|
||||
{
|
||||
throw new \DomainException();
|
||||
}
|
||||
|
||||
foreach( \IPS\Db::i()->select( '*', 'content_templates', array( 'template_master=?', 1 ) ) as $template )
|
||||
{
|
||||
if ( ! is_dir( $path . '/' . $template['template_location'] ) )
|
||||
{
|
||||
mkdir( $path . '/' . $template['template_location'] );
|
||||
@chmod( $path . '/' . $template['template_location'], \IPS\IPS_FOLDER_PERMISSION );
|
||||
}
|
||||
|
||||
if ( ! is_dir( $path . '/' . $template['template_location'] . '/' . $template['template_group'] ) )
|
||||
{
|
||||
mkdir( $path . '/' . $template['template_location'] . '/' . $template['template_group'] );
|
||||
@chmod( $path . '/' . $template['template_location'] . '/' . $template['template_group'], \IPS\IPS_FOLDER_PERMISSION );
|
||||
}
|
||||
|
||||
$container = NULL;
|
||||
|
||||
if ( ! file_exists( $path . '/' . $template['template_location'] . '/' . $template['template_group'] . '/' . $template['template_key'] . '.phtml' ) OR $force === TRUE )
|
||||
{
|
||||
try
|
||||
{
|
||||
if ( $template['template_container'] )
|
||||
{
|
||||
$item = \IPS\content\Templates\Container::load( $template['template_container'] );
|
||||
|
||||
$container = array(
|
||||
'name' => $item->name,
|
||||
'type' => $item->type,
|
||||
'parent_id' => $item->parent_id,
|
||||
'key' => $item->key
|
||||
);
|
||||
}
|
||||
}
|
||||
catch( \OutofRangeException $ex ) { }
|
||||
|
||||
$data = json_encode( array(
|
||||
'title' => $template['template_title'],
|
||||
'desc' => $template['template_desc'],
|
||||
'rel_id' => $template['template_rel_id'],
|
||||
'container' => $container
|
||||
) );
|
||||
|
||||
$write = '<ips:template parameters="' . $template['template_params'] . '" data="' . $data . '" />' . "\n";
|
||||
$write .= $template['template_content'];
|
||||
|
||||
if ( ! @\file_put_contents( $path . '/' . $template['template_location'] . '/' . $template['template_group'] . '/' . $template['template_key'] . '.phtml', $write ) )
|
||||
{
|
||||
throw new \RuntimeException( \IPS\Member::loggedIn()->language()->addToStack( 'content_theme_dev_cannot_write_template', FALSE, array( 'sprintf' => array( $path . '/' . $template['template_location'] . '/' . $template['template_group'] . '/' . $template['template_key'] ) ) ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
@chmod( $path . '/' . $template['template_location'] . '/' . $template['template_group'] . '/' . $template['template_key'] . '.phtml', 0777 );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a template
|
||||
*
|
||||
* @param string $group Template Group
|
||||
* @param string $app Application key (NULL for current application)
|
||||
* @param string $location Template Location (NULL for current template location)
|
||||
* @return \IPS\Theme\Template
|
||||
*/
|
||||
public function getTemplate( $group, $app=NULL, $location=NULL )
|
||||
{
|
||||
/* Do we have an application? */
|
||||
if( $app === NULL )
|
||||
{
|
||||
$app = \IPS\Dispatcher::i()->application->directory;
|
||||
}
|
||||
|
||||
/* How about a template location? */
|
||||
if( $location === NULL )
|
||||
{
|
||||
$location = \IPS\Dispatcher::i()->controllerLocation;
|
||||
}
|
||||
|
||||
/* Get template */
|
||||
if ( \IPS\IN_DEV )
|
||||
{
|
||||
if ( ! isset( $this->templates[ $app ][ $location ][ $group ] ) )
|
||||
{
|
||||
$this->templates[ $app ][ $location ][ $group ] = new \IPS\Email\Theme\Template( $app, $location, $group );
|
||||
}
|
||||
|
||||
return $this->templates[ $app ][ $location ][ $group ];
|
||||
}
|
||||
else
|
||||
{
|
||||
$key = \strtolower( 'template_email_' . static::makeBuiltTemplateLookupHash( $app, $location, $group ) . '_' . $group );
|
||||
|
||||
/* Still here */
|
||||
if ( !in_array( $key, array_keys( self::$calledTemplates ) ) )
|
||||
{
|
||||
/* If we don't have a compiled template, do that now */
|
||||
if ( !isset( \IPS\Data\Store::i()->$key ) )
|
||||
{
|
||||
$functions = array( 'html' => array(), 'plain' => array() );
|
||||
|
||||
/* Get it from the database */
|
||||
foreach( \IPS\Db::i()->select( '*', 'core_email_templates', array( "template_app=? AND template_name LIKE '" . \IPS\Db::i()->real_escape_string( $group ) . "__%'", $app ) ) as $data )
|
||||
{
|
||||
$funcName = mb_substr( $data['template_name'], ( mb_strlen( $group ) + 2 ) );
|
||||
$functions['html'][ $funcName ] = static::compileTemplate( $data['template_content_html'], $funcName, $data['template_data'], true );
|
||||
$functions['plain'][ $funcName ] = static::compileTemplate( $data['template_content_plaintext'], $funcName, $data['template_data'], true );
|
||||
}
|
||||
|
||||
/* Put them in a class */
|
||||
foreach( array( 'html', 'plain' ) as $type )
|
||||
{
|
||||
$var = 'template_' . $type;
|
||||
${$var} = <<<EOF
|
||||
namespace IPS\Theme;
|
||||
class class_email_{$app}_{$type}_{$group}
|
||||
{
|
||||
|
||||
EOF;
|
||||
${$var} .= implode( "\n\n", $functions[ $type ] );
|
||||
|
||||
${$var} .= <<<EOF
|
||||
}
|
||||
EOF;
|
||||
$storeKey = \strtolower( 'template_email_' . static::makeBuiltTemplateLookupHash( $app, $type, $group ) . '_' . $group );
|
||||
\IPS\Data\Store::i()->$storeKey = ${$var};
|
||||
}
|
||||
}
|
||||
|
||||
/* Load compiled template */
|
||||
$compiledGroup = \IPS\Data\Store::i()->$key;
|
||||
|
||||
if ( @eval( $compiledGroup ) === FALSE )
|
||||
{
|
||||
throw new \UnexpectedValueException;
|
||||
}
|
||||
|
||||
/* Hooks */
|
||||
$class = 'class_email_' . $app . '_' . $location . '_' . $group;
|
||||
$class = "\IPS\Theme\\{$class}";
|
||||
|
||||
/* Init */
|
||||
self::$calledTemplates[ $key ] = new $class();
|
||||
}
|
||||
|
||||
return self::$calledTemplates[ $key ];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path for the IN_DEV .phtml files
|
||||
* @param string $app Application Key
|
||||
* @param string|null $location Location
|
||||
* @param string|null $path Path or Filename
|
||||
* @return string
|
||||
*/
|
||||
protected static function _getHtmlPath( $app, $location=null, $path=null )
|
||||
{
|
||||
return rtrim( \IPS\ROOT_PATH . "/applications/{$app}/dev/html/{$location}/{$path}", '/' ) . '/';
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
Reference in new issue
Block a user