Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 17 Apr 2013 */ namespace IPS\Email\Outgoing; use IPS\Email; use IPS\Helpers\Form\Number; use IPS\Helpers\Form\Password; use IPS\Helpers\Form\Select; use IPS\Helpers\Form\Text; use IPS\Login; use IPS\Http\Url; use IPS\Request; use IPS\Settings; use function defined; use function intval; use function stream_socket_enable_crypto; use const IPS\DEFAULT_REQUEST_TIMEOUT; use const IPS\VERY_LONG_REQUEST_TIMEOUT; /* To prevent PHP errors (extending class does not exist) revealing path */ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * @brief SMTP Email Class */ class Smtp extends Email { /** * @brief SMTP Protocol ("tls", "ssl" or "plain") */ protected string $smtpProtocol; /** * @brief SMTP Host */ protected string $smtpHost; /** * @brief SMTP Port */ protected int $smtpPort; /** * @brief SMTP Username */ protected string $smtpUser; /** * @brief SMTP Password */ protected string $smtpPass; /** * @brief SMTP Connections */ protected static array $smtp = array(); /** * @brief Connection Key */ protected string $connectionKey; /** * @brief Log */ protected string $log = ''; /** * Constructor * * @param string $smtpProtocol Protocol to use * @param string $smtpHost Hostname to connect to * @param int $smtpPort Port to connect to * @param string $smtpUser Username * @param string $smtpPass Password * @return void */ public function __construct( string $smtpProtocol, string $smtpHost, int $smtpPort, string $smtpUser, string $smtpPass ) { $this->smtpProtocol = $smtpProtocol; $this->smtpHost = $smtpHost; $this->smtpPort = $smtpPort; $this->smtpUser = $smtpUser; $this->smtpPass = $smtpPass; $this->connectionKey = md5( $smtpProtocol . $smtpHost . $smtpPort . $smtpUser . $smtpPass ); } /** * Connect to server * * @param bool $checkSsl If set to FALSE, will skip peer certificate verification for TLS connections * @return void */ public function connect( bool $checkSsl=TRUE ) : void { /* Do we already have a connection? */ if( array_key_exists( $this->connectionKey, static::$smtp ) ) { return; } /* Connect */ $connection = @fsockopen( ( ( $this->smtpProtocol == 'ssl' ) ? 'ssl://' : '' ) . $this->smtpHost, $this->smtpPort, $errno, $errstr, Request::isCliEnvironment() ? VERY_LONG_REQUEST_TIMEOUT : DEFAULT_REQUEST_TIMEOUT ); if ( !$connection ) { throw new Exception( $errstr ?? '', $errno ?? 0 ); } 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(); $responseCode = mb_substr( $announce, 0, 3 ); if ( $responseCode != 220 ) { throw new Exception( 'smtpmail_fsock_error_initial', 0, NULL, array( $responseCode ) ); } /* HELO/EHLO */ $ehloFqdn = \IPS\Http\Url::internal('')->data['host']; try { $helo = 'EHLO'; $responseCode = $this->_sendCommand( 'EHLO ' . $ehloFqdn, 250 ); } catch (Exception $e ) { $helo = 'HELO'; $responseCode = $this->_sendCommand( 'HELO ' . $ehloFqdn, 250 ); } /* Is TLS being used? */ if( $this->smtpProtocol == 'tls' ) { if ( $checkSsl ) { @stream_context_set_option( static::$smtp[ $this->connectionKey ], 'ssl', 'verify_peer', false ); } $this->_sendCommand('STARTTLS', 220); if ( !@stream_socket_enable_crypto( static::$smtp[ $this->connectionKey ], TRUE, STREAM_CRYPTO_METHOD_SSLv23_CLIENT ) ) { if ( $checkSsl ) { /* Try again, but ignore SSL checks in case the certificate was self-signed, which will fail when initializing TLS. This will be slightly slower to connect, but will avoid an error in most instances. */ $this->connect( FALSE ); } else { /* If it still failed on the second connection attempt, throw the exception */ throw new Exception( 'smtpmail_tls_failed' ); } } /* Exchange server (at least) wants EHLO resending for STARTTLS */ $this->_sendCommand( $helo . ' ' . $ehloFqdn, 250 ); } /* Authenticate */ if ( $this->smtpUser ) { $responseCode = $this->_sendCommand('AUTH LOGIN', 334); $responseCode = $this->_sendCommand(base64_encode($this->smtpUser), 334); $responseCode = $this->_sendCommand(base64_encode($this->smtpPass), 235); } } /** * 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 Exception */ public function _send( mixed $to, mixed $cc=array(), mixed $bcc=array(), mixed $fromEmail = NULL, mixed $fromName = NULL, array $additionalHeaders = array() ) : void { /* Get the from email */ $fromEmail = $fromEmail ?: 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 */ $recipientsForSmtp = explode( ',', static::_parseRecipients( $to, TRUE ) ); if ( $cc ) { $recipientsForSmtp = array_merge( $recipientsForSmtp, explode( ',', static::_parseRecipients( $cc, TRUE ) ) ); } if ( $bcc ) { $recipientsForSmtp = array_merge( $recipientsForSmtp, explode( ',', static::_parseRecipients( $bcc, TRUE ) ) ); } $recipientsForSmtp = array_unique( array_map( 'trim', $recipientsForSmtp ) ); if( empty( $additionalHeaders['Message-ID'] ) ) { $additionalHeaders['Message-ID'] = $this->generateMessageId(); } /* Send */ $this->_sendCompiled( $fromEmail, $recipientsForSmtp, $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 * @return void * @throws Exception */ public function _sendCompiled( string $fromEmail, array $toEmails, string $email ) : void { $this->connect(); $this->_sendCommand("MAIL FROM:<{$fromEmail}>", 250); foreach ( $toEmails as $toEmail ) { $this->_sendCommand("RCPT TO:<{$toEmail}>", 250 ); } $this->_sendCommand('DATA', 354); $this->_sendCommand($email . "\r\n.", 250); } /** * Send SMTP Command * * @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 Exception */ protected function _sendCommand( string $command, ?int $expectedResponse=NULL, bool $resetOnFailure = TRUE): string { /* Log */ $this->log .= "> {$command}\r\n"; /* Send */ fputs( static::$smtp[ $this->connectionKey ], $command . "\r\n" ); /* Read */ $response = $this->_getResponse(); /* Get response code */ $code = intval( mb_substr( $response, 0, 3 ) ); if ( $expectedResponse !== NULL and $code !== $expectedResponse ) { if( $resetOnFailure === TRUE ) { $this->_sendCommand('RSET'); } throw new Exception( $response, $code ); } /* Return */ return $response; } /** * Get response * * @return string Response */ protected function _getResponse(): string { /* Read */ $response = ''; while ( $line = @fgets( static::$smtp[ $this->connectionKey ], 515 ) ) { $response .= $line; if ( mb_substr($line, 3, 1) == " " ) { break; } } /* Log */ $this->log .= mb_convert_encoding( $response, 'UTF-8', 'ASCII' ); /* Return */ return $response; } /** * Generate random message-id * * @return string */ public function generateMessageId(): string { $randomString = base_convert( microtime() . '.' . Login::generateRandomString(), 10, 36 ); return '<' . $randomString . '@' . Url::internal('')->data[ Url::COMPONENT_HOST ] . '>'; } /** * Return the SMTP log * * @return string */ public function getLog(): string { return $this->log; } /** * Form fields for email handler * These will automatically toggle on when the email handler is selected to be used * * @return array */ public static function form(): array { $form = []; $options = array( 'plain' => 'smtp_plaintext', 'ssl' => 'smtp_ssl', 'tls' => 'smtp_tls' ); $form['smtp_host'] = new Text( 'smtp_host', Settings::i()->smtp_host, FALSE, [], NULL, NULL, NULL, 'smtp_host' ); $form['smtp_protocol'] = new Select( 'smtp_protocol', Settings::i()->smtp_protocol, FALSE, [ 'options' => $options ], NULL, NULL, NULL, 'smtp_protocol' ); $form['smtp_port'] = new Number( 'smtp_port', Settings::i()->smtp_port, FALSE, [], NULL, NULL, NULL, 'smtp_port' ); $form['smtp_user'] = new Text( 'smtp_user', Settings::i()->smtp_user, FALSE, [], NULL, NULL, NULL, 'smtp_user' ); $form['smtp_pass'] = new Password( 'smtp_pass', Settings::i()->smtp_pass, FALSE, [ 'enforceMaxLimit' => FALSE ], NULL, NULL, NULL, 'smtp_pass' ) ; return $form; } /** * Is this email class usable? * * @param string $type Email Type * @return bool */ public static function isUsable( string $type ): bool { return true; } }