Invision Power Services, Inc. * @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; } }