418 lines
12 KiB
PHP
418 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* @brief SendGrid Email Class
|
|
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
|
|
* @copyright (c) Invision Power Services, Inc.
|
|
* @license https://www.invisioncommunity.com/legal/standards/
|
|
* @package Invision Community
|
|
* @since 17 Oct 2013
|
|
*/
|
|
|
|
namespace IPS\Email\Outgoing;
|
|
|
|
/* To prevent PHP errors (extending class does not exist) revealing path */
|
|
|
|
use IPS\core\Advertisement;
|
|
use IPS\core\extensions\core\CommunityEnhancements\SendGrid as SendGridIntegration;
|
|
use IPS\Db;
|
|
use IPS\Email;
|
|
use IPS\Http\Request\Exception as RequestException;
|
|
use IPS\Email\Outgoing\Exception as EmailException;
|
|
use IPS\Http\Url;
|
|
use IPS\Lang;
|
|
use IPS\Member;
|
|
use IPS\Settings;
|
|
use RuntimeException;
|
|
use function count;
|
|
use function defined;
|
|
use function is_array;
|
|
use const IPS\LONG_REQUEST_TIMEOUT;
|
|
|
|
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
|
{
|
|
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* SendGrid Email Class
|
|
*/
|
|
class SendGrid extends 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 API Key
|
|
*/
|
|
protected string $apiKey;
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param string $apiKey API Key
|
|
* @return void
|
|
*/
|
|
public function __construct( string $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 EmailException
|
|
*/
|
|
public function _send( mixed $to, mixed $cc=array(), mixed $bcc=array(), mixed $fromEmail = NULL, mixed $fromName = NULL, array $additionalHeaders = array() ) : void
|
|
{
|
|
/* 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 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 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 EmailException( $response['errors'][0]['message'] );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Is this email class usable?
|
|
*
|
|
* @param string $type Email Type
|
|
* @return bool
|
|
*/
|
|
public static function isUsable( string $type ): bool
|
|
{
|
|
if( ( new SendGridIntegration() )->enabled )
|
|
{
|
|
return match( true )
|
|
{
|
|
( $type == Email::TYPE_TRANSACTIONAL OR $type == Email::TYPE_LIST ) AND Settings::i()->sendgrid_use_for == 2 => true,
|
|
$type == Email::TYPE_BULK AND (int) Settings::i()->sendgrid_use_for > 0 => true,
|
|
default => false
|
|
};
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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 Lang|NULL $language The language the email content should be in
|
|
* @return int Number of successful sends
|
|
*/
|
|
public function mergeAndSend( array $recipients, mixed $fromEmail = NULL, mixed $fromName = NULL, array $additionalHeaders = array(), Lang $language = NULL ): int
|
|
{
|
|
/* Initiate the request */
|
|
$request = $this->_initRequest( $fromEmail, $fromName );
|
|
|
|
/* Add the subject and content */
|
|
$subject = $this->compileSubject( NULL, $language );
|
|
$htmlContent = $this->compileContent( 'html', null, $language );
|
|
$plaintextContent = preg_replace( '/\*\|(.+?)\|\*/', '*|$1_plain|*', $this->compileContent( 'plaintext', null, $language ) );
|
|
$request['subject'] = preg_replace( '/\*\|(.+?)\|\*/', '*|$1_plain|*', $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}_plain|*"] = $v;
|
|
$finalSubstitutions["*|{$k}|*"] = htmlspecialchars( $v, ENT_QUOTES | ENT_DISALLOWED, 'UTF-8' );
|
|
}
|
|
|
|
$request['personalizations'][] = array(
|
|
'to' => array( array( 'email' => $email ) ),
|
|
'substitutions' => $finalSubstitutions
|
|
);
|
|
}
|
|
|
|
/* Add additional headers */
|
|
$request = $this->_modifyRequestDataWithHeaders( $request, $additionalHeaders );
|
|
|
|
/* Log emails sent */
|
|
$this->_trackStatistics( count( $addresses ) );
|
|
|
|
/* Send */
|
|
try
|
|
{
|
|
$response = $this->_api( 'mail/send', $request );
|
|
}
|
|
catch(RequestException $e )
|
|
{
|
|
Db::i()->insert( 'core_mail_error_logs', array(
|
|
'mlog_date' => time(),
|
|
'mlog_to' => json_encode( $addresses ),
|
|
'mlog_from' => $fromEmail ?: Settings::i()->email_out,
|
|
'mlog_subject' => $subject,
|
|
'mlog_content' => $htmlContent,
|
|
'mlog_resend_data' => NULL,
|
|
'mlog_msg' => json_encode( array( 'message' => $e->getMessage() ) ),
|
|
'mlog_smtp_log' => NULL
|
|
) );
|
|
|
|
return 0;
|
|
}
|
|
|
|
$errorcount = 0;
|
|
if ( isset( $response['errors'] ) )
|
|
{
|
|
$errorcount = count( $response['errors'] );
|
|
Db::i()->insert( 'core_mail_error_logs', array(
|
|
'mlog_date' => time(),
|
|
'mlog_to' => json_encode( $addresses ),
|
|
'mlog_from' => $fromEmail ?: Settings::i()->email_out,
|
|
'mlog_subject' => $subject,
|
|
'mlog_content' => $htmlContent,
|
|
'mlog_resend_data' => NULL,
|
|
'mlog_msg' => json_encode( array( 'message' => $response['errors'] ) ),
|
|
'mlog_smtp_log' => NULL
|
|
) );
|
|
}
|
|
|
|
$successCount = ( count( $recipients ) - $errorcount );
|
|
|
|
/* Update ad impression count */
|
|
Advertisement::updateEmailImpressions( $successCount );
|
|
|
|
return $successCount;
|
|
}
|
|
|
|
/**
|
|
* Create a request
|
|
*
|
|
* @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
|
|
* @return array
|
|
*/
|
|
protected function _initRequest( mixed $fromEmail = NULL, mixed $fromName = NULL ): array
|
|
{
|
|
$request = array(
|
|
'personalizations' => array(),
|
|
'from' => array(
|
|
'email' => $fromEmail ?: Settings::i()->email_out,
|
|
'name' => $fromName ?: Settings::i()->board_name
|
|
),
|
|
'tracking_settings' => array(
|
|
'click_tracking' => array(
|
|
'enable' => false,
|
|
'enable_text' => false,
|
|
),
|
|
'open_tracking' => array(
|
|
'enable' => (bool) Settings::i()->sendgrid_click_tracking,
|
|
)
|
|
)
|
|
);
|
|
|
|
if ( Settings::i()->sendgrid_ip_pool )
|
|
{
|
|
$request['ip_pool_name'] = 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( array $request, array $additionalHeaders = array(), array $allowedTags = array() ): 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 array|null $args Arguments
|
|
* @return array|null
|
|
*@throws Exception Indicates an invalid JSON response or HTTP error
|
|
*/
|
|
protected function _api(string $method, array $args=NULL ): ?array
|
|
{
|
|
$request = Url::external( 'https://api.sendgrid.com/v3/' . $method )
|
|
->request( 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 ( Exception | RuntimeException $e )
|
|
{
|
|
throw new Exception($e->getMessage(), $e->getCode() );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get API key scopes
|
|
*
|
|
* @return array|null
|
|
*/
|
|
public function scopes(): ?array
|
|
{
|
|
return $this->_api( 'scopes' );
|
|
}
|
|
|
|
/**
|
|
* Parse/Save email form settings
|
|
*
|
|
* @param array $values settings array
|
|
* @return array
|
|
*/
|
|
public static function processSettings( array $values ): array
|
|
{
|
|
if ( isset( $values['mail_method'] ) and $values['mail_method'] != 'sendgrid' and Settings::i()->sendgrid_use_for == 2 )
|
|
{
|
|
$values['sendgrid_use_for'] = 1;
|
|
}
|
|
elseif ( isset( $values['mail_method'] ) and $values['mail_method'] == 'sendgrid' )
|
|
{
|
|
$values['sendgrid_use_for'] = 2;
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
} |