Merge branch 'support/3.2' into develop

This commit is contained in:
Stephen Abello
2025-09-18 10:13:09 +02:00
619 changed files with 20218 additions and 81084 deletions

View File

@@ -1,124 +0,0 @@
<?php
namespace Combodo\iTop\Core\Authentication\Client\Smtp;
use Combodo\iTop\Core\Authentication\Client\OAuth\OAuthClientProviderAbstract;
use IssueLog;
use Laminas\Mail\Protocol\Exception\RuntimeException;
use Laminas\Mail\Protocol\Smtp\Auth\Login;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
class Oauth extends Login
{
/**
* LOGIN username
*
* @var OAuthClientProviderAbstract
*/
protected static $oProvider;
const LOG_CHANNEL = 'OAuth';
/**
* Constructor.
*
* @param string $host (Default: 127.0.0.1)
* @param null $port (Default: null)
* @param null $config Auth-specific parameters
*/
public function __construct($host = '127.0.0.1', $port = null, $config = null)
{
$origConfig = $config;
if (is_array($host)) {
// Merge config array with principal array, if provided
if (is_array($config)) {
$config = array_replace_recursive($host, $config);
} else {
$config = $host;
}
}
if (is_array($config)) {
if (isset($config['username'])) {
$this->setUsername($config['username']);
}
}
// Call parent with original arguments
parent::__construct($host, $port, $origConfig);
}
/**
* @param OAuthClientProviderAbstract $oProvider
*
* @return void
*/
public static function setProvider(OAuthClientProviderAbstract $oProvider)
{
self::$oProvider = $oProvider;
}
/**
* Perform LOGIN authentication with supplied credentials
*
*/
public function auth()
{
try {
if (empty(self::$oProvider->GetAccessToken())) {
throw new IdentityProviderException('Not prior authentication to OAuth', 255, []);
} elseif (self::$oProvider->GetAccessToken()->hasExpired()) {
self::$oProvider->SetAccessToken(self::$oProvider->GetVendorProvider()->getAccessToken('refresh_token', [
'refresh_token' => self::$oProvider->GetAccessToken()->getRefreshToken(),
'scope' => self::$oProvider->GetScope(),
]));
}
}
catch (IdentityProviderException $e) {
IssueLog::Error('Failed to get oAuth credentials for outgoing mails for provider '.self::$oProvider::GetVendorName().' '.$e->getMessage(), static::LOG_CHANNEL);
return false;
}
$sAccessToken = self::$oProvider->GetAccessToken()->getToken();
if (empty($sAccessToken)) {
IssueLog::Error('No OAuth token for outgoing mails for provider '.self::$oProvider::GetVendorName(), static::LOG_CHANNEL);
return false;
}
$this->_send('AUTH XOAUTH2 '.base64_encode("user=$this->username\1auth=Bearer $sAccessToken\1\1"));
IssueLog::Debug("SMTP Oauth sending AUTH XOAUTH2 user=$this->username auth=Bearer $sAccessToken", static::LOG_CHANNEL);
try {
while (true) {
$sResponse = $this->_receive(30);
IssueLog::Debug("SMTP Oauth receiving ".trim($sResponse), static::LOG_CHANNEL);
if ($sResponse === '+') {
// Send empty client response.
$this->_send('');
} else {
if (preg_match('/Unauthorized/i', $sResponse) ||
preg_match('/Rejected/i', $sResponse) ||
preg_match('/^(535|432|454|534|500|530|538|334)/', $sResponse)) {
IssueLog::Error('Unable to authenticate for outgoing mails for provider '.self::$oProvider::GetVendorName()." Error: $sResponse", static::LOG_CHANNEL);
return false;
}
if (preg_match("/OK /i", $sResponse) ||
preg_match('/Accepted/i', $sResponse) ||
preg_match('/^235/i', $sResponse)) {
$this->auth = true;
return true;
}
}
}
} catch (RuntimeException $e) {
IssueLog::Error('Timeout connection for outgoing mails for provider '.self::$oProvider::GetVendorName(), static::LOG_CHANNEL);
}
return false;
}
}

View File

@@ -2,12 +2,10 @@
namespace Combodo\iTop\Core\Email;
use Combodo\iTop\Core\Email\EMailLaminas;
class EmailFactory
{
public static function GetMailer()
{
return EMailLaminas::GetMailer();
return EMailSymfony::GetMailer();
}
}

View File

@@ -2,7 +2,7 @@
/**
* Send an email (abstraction for synchronous/asynchronous modes)
*
* @copyright Copyright (C) 2010-2024 Combodo SAS
* @copyright Copyright (C) 2010-2025 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
@@ -10,6 +10,8 @@ namespace Combodo\iTop\Core\Email;
use AsyncSendEmail;
use Combodo\iTop\Core\Authentication\Client\OAuth\OAuthClientProviderFactory;
use Combodo\iTop\Core\Email\Transport\SymfonyFileTransport;
use Combodo\iTop\Core\Email\Transport\SymfonyOAuthTransport;
use DOMDocument;
use DOMXPath;
use EMail;
@@ -17,25 +19,22 @@ use Exception;
use ExecutionKPI;
use InlineImage;
use IssueLog;
use Laminas\Mail\Header\ContentType;
use Laminas\Mail\Header\InReplyTo;
use Laminas\Mail\Header\MessageId;
use Laminas\Mail\Message;
use Combodo\iTop\Core\Authentication\Client\Smtp\Oauth;
use Laminas\Mail\Transport\File;
use Laminas\Mail\Transport\FileOptions;
use Laminas\Mail\Transport\Sendmail;
use Laminas\Mail\Transport\Smtp;
use Laminas\Mail\Transport\SmtpOptions;
use Laminas\Mime\Mime;
use Laminas\Mime\Part;
use MetaModel;
use Pelago\Emogrifier\CssInliner;
use Pelago\Emogrifier\HtmlProcessor\CssToAttributeConverter;
use Pelago\Emogrifier\HtmlProcessor\HtmlPruner;
use Symfony\Component\CssSelector\Exception\ParseException;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mime\Email as SymfonyEmail;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\RelatedPart;
use Symfony\Component\Mime\Part\Multipart\MixedPart;
use Symfony\Component\Mime\Part\Multipart\AlternativePart;
use Symfony\Component\Mime\Part\TextPart;
class EMailLaminas extends Email
class EMailSymfony extends Email
{
// Serialization formats
const ORIGINAL_FORMAT = 1; // Original format, consisting in serializing the whole object, inculding the Swift Mailer's object.
@@ -53,8 +52,7 @@ class EMailLaminas extends Email
public function __construct()
{
$this->m_aData = array();
$this->m_oMessage = new Message();
$this->m_oMessage->setEncoding('UTF-8');
$this->m_oMessage = new SymfonyEmail();
$this->InitRecipientFrom();
}
@@ -133,28 +131,30 @@ class EMailLaminas extends Email
}
catch (Exception $e) {
$aIssues = array($e->GetMessage());
return EMAIL_SEND_ERROR;
}
$aIssues = array();
return EMAIL_SEND_PENDING;
}
public static function GetMailer()
{
return new EMailLaminas();
return new EMailSymfony();
}
/**
* Send synchronously using symfony/mailer
*
* @throws \Exception
*/
protected function SendSynchronous(&$aIssues, $oLog = null)
{
$this->LoadConfig();
$sTransport = self::$m_oConfig->Get('email_transport');
$oMailer = null;
$oTransport = null;
switch ($sTransport) {
case 'SMTP':
$sHost = self::$m_oConfig->Get('email_transport_smtp.host');
@@ -164,81 +164,92 @@ class EMailLaminas extends Email
$sPassword = self::$m_oConfig->Get('email_transport_smtp.password');
$bVerifyPeer = static::$m_oConfig->Get('email_transport_smtp.verify_peer');
$oTransport = new Smtp();
$aOptions = [];
$aConnectionConfig = [];
$aOptions['host'] = $sHost;
$aOptions['port'] = $sPort;
// Build the DSN string
$sDsnUser = $sUserName !== null ? rawurlencode($sUserName) : '';
$sDsnPassword = ($sPassword !== null && $sPassword !== '') ? ':' . rawurlencode($sPassword) : '';
$sDsnPort = $sHost . (strlen($sPort) ? ':' . $sPort : '');
$sDsn = null;
$aConnectionConfig['ssl'] = $sEncryption;
$aConnectionConfig['novalidatecert'] = !$bVerifyPeer;
if (strlen($sPassword) > 0) {
$aConnectionConfig['username'] = $sUserName;
$aConnectionConfig['password'] = $sPassword;
$aOptions['connection_class'] = 'login';
if (strtolower($sEncryption) === 'ssl') {
// Implicit TLS (smtps)
$sDsn = sprintf('smtps://%s%s@%s', $sDsnUser, $sDsnPassword, $sDsnPort);
} else {
$aOptions['connection_class'] = 'smtp';
// Regular smtp, can enable starttls via query param
$sEncQuery = '';
if (strtolower($sEncryption) === 'tls') {
$sEncQuery = '?encryption=starttls';
}
$sDsn = sprintf('smtp://%s%s@%s%s', $sDsnUser, $sDsnPassword, $sDsnPort, $sEncQuery);
}
$aOptions['connection_config'] = $aConnectionConfig;
$oTransport = Transport::fromDsn($sDsn);
$oOptions = new SmtpOptions($aOptions);
$oTransport->setOptions($oOptions);
// Handle peer verification
$oStream = $oTransport->getStream();
$aOptions= $oStream->getStreamOptions();
if (!$bVerifyPeer && array_key_exists('ssl', $aOptions)) {
// Disable verification
$aOptions['ssl']['verify_peer'] = false;
$aOptions['ssl']['verify_peer_name'] = false;
$aOptions['ssl']['allow_self_signed'] = true;
}
$oStream->setStreamOptions($aOptions);
$oMailer = new Mailer($oTransport);
break;
case 'SMTP_OAuth':
// Use custom SMTP transport
$sHost = self::$m_oConfig->Get('email_transport_smtp.host');
$sPort = self::$m_oConfig->Get('email_transport_smtp.port');
$sEncryption = self::$m_oConfig->Get('email_transport_smtp.encryption');
$sUserName = self::$m_oConfig->Get('email_transport_smtp.username');
$oTransport = new Smtp();
$aOptions = [
'host' => $sHost,
'port' => $sPort,
'connection_class' => 'Laminas\Mail\Protocol\Smtp\Auth\Oauth',
'connection_config' => [
'ssl' => $sEncryption,
],
];
if (strlen($sUserName) > 0) {
$aOptions['connection_config']['username'] = $sUserName;
}
$oOptions = new SmtpOptions($aOptions);
$oTransport->setOptions($oOptions);
Oauth::setProvider(OAuthClientProviderFactory::GetProviderForSMTP());
$oTransport = new SymfonyOAuthTransport([
'host' => $sHost,
'port' => $sPort,
'encryption' => $sEncryption,
'username' => $sUserName,
]);
$oMailer = new Mailer($oTransport);
SymfonyOAuthTransport::setProvider(OAuthClientProviderFactory::GetProviderForSMTP());
break;
case 'Null':
$oTransport = new Smtp();
// Use a dummy transport
$oTransport = Transport::fromDsn('null://null');
$oMailer = new Mailer($oTransport);
break;
case 'LogFile':
$oTransport = new File();
$aOptions = new FileOptions([
'path' => APPROOT.'log/',
'callback' => function() { return 'mail.log'; }
]);
$oTransport->setOptions($aOptions);
// Use a custom transport that writes to a log file
// Note: the log file is not rotated, so this should be used for debugging
$oTransport = new SymfonyFileTransport(APPROOT . 'log/', 'mail.log');
$oMailer = new Mailer($oTransport);
break;
case 'PHPMail':
default:
$oTransport = new Sendmail();
// Use sendmail transport
$oTransport = Transport::fromDsn('sendmail://default');
$oMailer = new Mailer($oTransport);
}
$oKPI = new ExecutionKPI();
try {
$oTransport->send($this->m_oMessage);
if ($oMailer === null || $oTransport === null) {
throw new \RuntimeException('No mailer transport configured.');
}
$oMailer->send($this->m_oMessage);
$aIssues = array();
$oKPI->ComputeStats('Email Sent', 'Succeded');
return EMAIL_SEND_OK;
}
catch (Laminas\Mail\Transport\Exception\RuntimeException $e) {
catch (TransportExceptionInterface $e) {
IssueLog::Warning('Email sending failed: '.$e->getMessage());
$aIssues = array($e->getMessage());
$oKPI->ComputeStats('Email Sent', 'Error received');
@@ -266,14 +277,13 @@ class EMailLaminas extends Email
{
$oDOMDoc = new DOMDocument();
$oDOMDoc->preserveWhiteSpace = true;
@$oDOMDoc->loadHTML('<?xml encoding="UTF-8"?>'.$sBody); // For loading HTML chunks where the character set is not specified
@$oDOMDoc->loadHTML('<?xml encoding="UTF-8"?>'.$sBody);
$oXPath = new DOMXPath($oDOMDoc);
$sXPath = '//img[@'.InlineImage::DOM_ATTR_ID.']';
$oImagesList = $oXPath->query($sXPath);
$oImagesContent = new \Laminas\Mime\Message();
$aImagesParts = [];
if ($oImagesList->length != 0) {
if ($oImagesList->length !== 0) {
foreach ($oImagesList as $oImg) {
$iAttId = $oImg->getAttribute(InlineImage::DOM_ATTR_ID);
$oAttachment = MetaModel::GetObject('InlineImage', $iAttId, false, true /* Allow All Data */);
@@ -287,19 +297,14 @@ class EMailLaminas extends Email
}
$oDoc = $oAttachment->Get('contents');
// CID expects to be unique and to contain a @, see RFC 2392
$sCid = uniqid('', true).'@openitop.org';
$sCid = uniqid('', true);
$oPart = new DataPart($oDoc->GetData(), $oDoc->GetFileName(), $oDoc->GetMimeType());
$oPart->setContentId($sCid)->asInline();
$oNewAttachment = new Part($oDoc->GetData());
$oNewAttachment->id = $sCid;
$oNewAttachment->type = $oDoc->GetMimeType();
$oNewAttachment->filename = $oDoc->GetFileName();
$oNewAttachment->disposition = Mime::DISPOSITION_INLINE;
$oNewAttachment->encoding = Mime::ENCODING_BASE64;
$oImagesContent->addPart($oNewAttachment);
$aImagesParts[] = $oPart;
$oImg->setAttribute('src', 'cid:'.$sCid);
$aImagesParts[] = $oNewAttachment;
}
}
}
@@ -311,17 +316,6 @@ class EMailLaminas extends Email
/**
* Sends an e-mail.
*
* @param string[] $aIssues Array to add any potentially encountered issues to.
* @param int|bool $iSyncAsync Specify whether the e-mail will be sent per default configuration, or whether there will be a forced (a)synchronous sending. One of Email::ENUM_SEND_* constants. To support legacy, it also allows a boolean (true = send synchronous).
* @param Object|null $oLog Log
*
* @details By default, the send method will respect the preference to send e-mails in an (a)synchronous way as defined in the iTop configuration by the administrator.
* In some use cases, it may be necessary to override this behavior. For example, for some tests it may be best if e-mails are always sent instantly (synchronous).
* Asynchronous may be preferred when sending a lot of bulk e-mails at once, to avoid hitting rate limits of e-mail providers (e.g. customer survey extension).
*
* @since 3.2.0 Previously, $iSyncAsync was a boolean ($bForceSynchronous) and this method only allowed to forcefully send e-mails synchronously even when the default was asynchronous.
*
* @return
*/
public function Send(&$aIssues, $iSyncAsync = Email::ENUM_SEND_DEFAULT, $oLog = null)
{
@@ -330,24 +324,16 @@ class EMailLaminas extends Email
$this->SetRecipientFrom($this->m_aData['to']);
}
// In previous iTop versions, $iSyncAsync was $bForceSynchronous. To retain backward compatibility, this check is in place.
if($iSyncAsync === true) {
// This legacy mode forces synchronous sending, ignoring whatever default was configured.
return $this->SendSynchronous($aIssues, $oLog);
} else {
switch($iSyncAsync) {
case Email::ENUM_SEND_FORCE_SYNCHRONOUS:
return $this->SendSynchronous($aIssues, $oLog);
case Email::ENUM_SEND_FORCE_ASYNCHRONOUS:
return $this->SendAsynchronous($aIssues, $oLog);
case Email::ENUM_SEND_DEFAULT:
default:
// Default behavior.
$oConfig = $this->LoadConfig();
$bConfigASYNC = $oConfig->Get('email_asynchronous');
if($bConfigASYNC) {
@@ -359,6 +345,9 @@ class EMailLaminas extends Email
}
}
/**
* Add a header line
*/
public function AddToHeader($sKey, $sValue)
{
if (!array_key_exists('headers', $this->m_aData)) {
@@ -367,8 +356,7 @@ class EMailLaminas extends Email
$this->m_aData['headers'][$sKey] = $sValue;
if (strlen($sValue) > 0) {
$oHeaders = $this->m_oMessage->getHeaders();
$oHeaders->addHeaderLine($sKey, $sValue);
$this->m_oMessage->getHeaders()->addTextHeader($sKey, $sValue);
}
}
@@ -380,7 +368,7 @@ class EMailLaminas extends Email
// so let's remove the angle brackets if present, for historical reasons
$sId = str_replace(array('<', '>'), '', $sId);
$this->m_oMessage->getHeaders()->addHeader((new MessageId())->setId($sId));
$this->m_oMessage->getHeaders()->addIdHeader('Message-ID', $sId);
}
public function SetReferences($sReferences)
@@ -388,86 +376,77 @@ class EMailLaminas extends Email
$this->AddToHeader('References', $sReferences);
}
/**
* Set the "In-Reply-To" header to allow emails to group as a conversation in modern mail clients (GMail, Outlook 2016+, ...)
*
* @link https://en.wikipedia.org/wiki/Email#Header_fields
*
* @param string $sMessageId
*
* @since 3.0.1 N°4849
*/
public function SetInReplyTo(string $sMessageId)
{
// Note: Laminas will add the angle brackets for you
// so let's remove the angle brackets if present, for historical reasons
// Note: Symfony will add the angle brackets
// let's remove the angle brackets if present, for historical reasons
$sId = str_replace(array('<', '>'), '', $sMessageId);
$this->m_aData['in_reply_to'] = '<' . $sId . '>';
$this->m_oMessage->getHeaders()->addHeader((new InReplyTo())->setIds([$sId]));
$this->m_oMessage->getHeaders()->addTextHeader('In-Reply-To', '<' . $sId . '>');
}
/**
* Set current Email body and process inline images.
*
* @param $sBody
* @param string $sMimeType
* @param null $sCustomStyles
*
* @return void
* @throws \ArchivedObjectException
* @throws \CoreException
* @throws \Symfony\Component\CssSelector\Exception\ParseException
*/
public function SetBody($sBody, $sMimeType = Mime::TYPE_HTML, $sCustomStyles = null)
public function SetBody($sBody, $sMimeType = 'text/html', $sCustomStyles = null)
{
$oBody = new \Laminas\Mime\Message();
$aAdditionalParts = [];
if ($sMimeType === Mime::TYPE_HTML) {
// Inline CSS if needed
if ($sMimeType === 'text/html') {
$sBody = static::InlineCssIntoBodyContent($sBody, $sCustomStyles);
}
$this->m_aData['body'] = array('body' => $sBody, 'mimeType' => $sMimeType);
// We don't want these modifications in m_aData['body'], otherwise it'll ruin asynchronous mail as they go through this method twice
if ($sMimeType === Mime::TYPE_HTML) {
$oTextPart = new TextPart(strip_tags($sBody), 'utf-8', 'plain', 'base64');
// Embed inline images and store them in attachments (so BuildSymfonyMessageFromInternal can pick them)
if ($sMimeType === 'text/html') {
$aAdditionalParts = $this->EmbedInlineImages($sBody);
$oHtmlPart = new TextPart($sBody, 'utf-8', 'html', 'base64');
$oAlternativePart = new AlternativePart($oHtmlPart, $oTextPart);
// Default root part is the HTML body
$oRootPart = $oAlternativePart;
if(count($aAdditionalParts) > 0) {
$aRelatedParts = array_merge([$oAlternativePart], $aAdditionalParts);
$oRootPart = new RelatedPart(...$aRelatedParts);
}
}
else {
// Default root part is the text body
$oRootPart = $oTextPart;
}
// Add body content to as a new part
$oNewPart = new Part($sBody);
$oNewPart->encoding = Mime::ENCODING_BASE64;
$oNewPart->type = $sMimeType;
$oNewPart->charset = 'UTF-8';
$oBody->addPart($oNewPart);
$this->m_oMessage->setBody($oRootPart);
}
// Add additional images as new body parts
foreach ($aAdditionalParts as $oAdditionalPart) {
$oBody->addPart($oAdditionalPart);
protected function GetMimeSubtype($sMimeType, $sDefault = 'html')
{
$sMimeSubtype = '';
if (strpos($sMimeType, '/') !== false) {
$aParts = explode('/', $sMimeType);
if (count($aParts) > 1) {
$sMimeSubtype = $aParts[1];
}
}
$this->m_oMessage->setBody($oBody);
return $sMimeSubtype !== '' ?? $sDefault;
}
/**
* Add a new part to the existing body
*
* @param $sText
* @param string $sMimeType
*
* @return void
*/
public function AddPart($sText, $sMimeType = Mime::TYPE_HTML)
public function AddPart($sText, $sMimeType = 'text/html')
{
$sMimeSubtype = $this->GetMimeSubtype($sMimeType);
if (!array_key_exists('parts', $this->m_aData)) {
$this->m_aData['parts'] = array();
}
$this->m_aData['parts'][] = array('text' => $sText, 'mimeType' => $sMimeType);
$oNewPart = new Part($sText);
$oNewPart->encoding = Mime::ENCODING_BASE64;
$oNewPart->type = $sMimeType;
// setBody called only to refresh Content-Type to multipart/mixed
$this->m_oMessage->setBody($this->m_oMessage->getBody()->addPart($oNewPart));
$oNewPart = new TextPart($sText, $sMimeType, $sMimeSubtype, 'base64');
$this->m_oMessage->addPart($oNewPart);
}
public function AddAttachment($data, $sFileName, $sMimeType)
@@ -476,20 +455,27 @@ class EMailLaminas extends Email
$this->m_aData['attachments'] = array();
}
$this->m_aData['attachments'][] = array('data' => base64_encode($data), 'filename' => $sFileName, 'mimeType' => $sMimeType);
$oNewAttachment = new Part($data);
$oNewAttachment->type = $sMimeType;
$oNewAttachment->filename = $sFileName;
$oNewAttachment->disposition = Mime::DISPOSITION_ATTACHMENT;
$oNewAttachment->encoding = Mime::ENCODING_BASE64;
// setBody called only to refresh Content-Type to multipart/mixed
$this->m_oMessage->setBody($this->m_oMessage->getBody()->addPart($oNewAttachment));
$oBody = $this->m_oMessage->getBody();
$oRootPart = $oBody;
$aAttachmentPart = new DataPart($data, $sFileName, $sMimeType, 'base64');
if( $oBody instanceof MixedPart) {
$aCurrentParts = $oBody->getParts();
$aCurrentParts[] = $aAttachmentPart;
$oRootPart = new MixedPart(...$aCurrentParts);
}
else {
$oRootPart = new MixedPart($oBody, $aAttachmentPart);
}
$this->m_oMessage->setBody($oRootPart);
}
public function SetSubject($sSubject)
{
$this->m_aData['subject'] = $sSubject;
$this->m_oMessage->setSubject($sSubject);
$this->m_oMessage->subject($sSubject);
}
public function GetSubject()
@@ -499,7 +485,6 @@ class EMailLaminas extends Email
/**
* Helper to transform and sanitize addresses
* - get rid of empty addresses
*/
protected function AddressStringToArray($sAddressCSVList)
{
@@ -510,7 +495,6 @@ class EMailLaminas extends Email
$aAddresses[] = $sAddress;
}
}
return $aAddresses;
}
@@ -519,34 +503,30 @@ class EMailLaminas extends Email
$this->m_aData['to'] = $sAddress;
if (!empty($sAddress)) {
$aAddresses = $this->AddressStringToArray($sAddress);
$this->m_oMessage->setTo($aAddresses);
$this->m_oMessage->to(...$aAddresses);
}
}
public function GetRecipientTO($bAsString = false)
{
$aRes = $this->m_oMessage->getTo();
if ($aRes === null || $aRes->count() === 0) {
// There is no "To" header field
$aRes = array();
}
if ($bAsString) {
$aStrings = array();
foreach ($aRes as $oEmail) {
$sName = $oEmail->getName();
$sEmail = $oEmail->getEmail();
if (is_null($sName)) {
$sEmail = $oEmail->getAddress();
if (empty($sName)) {
$aStrings[] = $sEmail;
} else {
$sName = str_replace(array('<', '>'), '', $sName);
$aStrings[] = "$sName <$sEmail>";
}
}
return implode(', ', $aStrings);
} else {
return $aRes;
}
return $aRes;
}
public function SetRecipientCC($sAddress)
@@ -554,7 +534,7 @@ class EMailLaminas extends Email
$this->m_aData['cc'] = $sAddress;
if (!empty($sAddress)) {
$aAddresses = $this->AddressStringToArray($sAddress);
$this->m_oMessage->setCc($aAddresses);
$this->m_oMessage->cc(...$aAddresses);
}
}
@@ -563,7 +543,7 @@ class EMailLaminas extends Email
$this->m_aData['bcc'] = $sAddress;
if (!empty($sAddress)) {
$aAddresses = $this->AddressStringToArray($sAddress);
$this->m_oMessage->setBcc($aAddresses);
$this->m_oMessage->bcc(...$aAddresses);
}
}
@@ -571,9 +551,9 @@ class EMailLaminas extends Email
{
$this->m_aData['from'] = array('address' => $sAddress, 'label' => $sLabel);
if ($sLabel != '') {
$this->m_oMessage->setFrom(array($sAddress => $sLabel));
$this->m_oMessage->from(sprintf('%s <%s>', $sLabel, $sAddress));
} else if (!empty($sAddress)) {
$this->m_oMessage->setFrom($sAddress);
$this->m_oMessage->from($sAddress);
}
}
@@ -581,9 +561,9 @@ class EMailLaminas extends Email
{
$this->m_aData['reply_to'] = array('address' => $sAddress, 'label' => $sLabel);
if ($sLabel != '') {
$this->m_oMessage->setReplyTo(array($sAddress => $sLabel));
$this->m_oMessage->replyTo(sprintf('%s <%s>', $sLabel, $sAddress));
} else if (!empty($sAddress)) {
$this->m_oMessage->setReplyTo($sAddress);
$this->m_oMessage->replyTo($sAddress);
}
}
@@ -592,7 +572,7 @@ class EMailLaminas extends Email
* @param string $sCustomStyles
*
* @return string
* @throws \Symfony\Component\CssSelector\Exception\ParseException
* @throws ParseException
* @noinspection PhpUnnecessaryLocalVariableInspection
*/
protected static function InlineCssIntoBodyContent($sBody, $sCustomStyles): string
@@ -603,9 +583,8 @@ class EMailLaminas extends Email
$oDomDocument = CssInliner::fromHtml($sBody)->inlineCss($sCustomStyles)->getDomDocument();
HtmlPruner::fromDomDocument($oDomDocument)->removeElementsWithDisplayNone();
$sBody = CssToAttributeConverter::fromDomDocument($oDomDocument)->convertCssToVisualAttributes()->render(); // Adds html/body tags if not already present
$sBody = CssToAttributeConverter::fromDomDocument($oDomDocument)->convertCssToVisualAttributes()->render();
return $sBody;
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Combodo\iTop\Core\Email\Transport;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mailer\SentMessage;
class SymfonyFileTransport extends AbstractTransport
{
protected string $sDir;
protected string $sFilename;
/**
* @param string|null $sLogDir Directory where the file will be written. Defaults to APPROOT.'log/'.
* @param string $sFilename Filename (default 'mail.log').
*/
public function __construct(?string $sLogDir = null, string $sFilename = 'mail.log', ?EventDispatcherInterface $oDispatcher = null, ?LoggerInterface $oLogger = null)
{
parent::__construct($oDispatcher, $oLogger);
$this->sDir = rtrim($sLogDir ?? APPROOT . 'log/', DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$this->sFilename = $sFilename;
}
/**
* Write the message to the file.
*
* @param SentMessage $message
*
* @throws \RuntimeException
*/
protected function doSend(SentMessage $message): void
{
// Ensure directory exists
if (!is_dir($this->sDir) && !mkdir($concurrentDirectory = $this->sDir, 0755, true) && !is_dir($concurrentDirectory)) {
throw new \RuntimeException("Unable to create log directory: {$this->sDir}");
}
$sPath = $this->sDir.$this->sFilename;
// Build an entry header to separate messages
$sEntry = "=== ".date('c')." ===\n";
// Get the raw message
$oRawMessage = $message->getOriginalMessage();
if (is_object($oRawMessage) && method_exists($oRawMessage, 'toString')) {
$sEntry .= $oRawMessage->toString();
}
// Write using LOCK_EX to avoid race conditions
if (@file_put_contents($sPath, $sEntry, FILE_APPEND | LOCK_EX) === false) {
throw new \RuntimeException("Unable to write email entry to log file: {$sPath}");
}
}
public function __toString(): string
{
return 'logfile';
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace Combodo\iTop\Core\Email\Transport;
use Combodo\iTop\Core\Authentication\Client\OAuth\OAuthClientProviderAbstract;
use Combodo\iTop\Core\Authentication\Client\OAuth\OAuthClientProviderFactory;
use IssueLog;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\Smtp\Auth\LoginAuthenticator;
use Symfony\Component\Mailer\Transport\Smtp\Auth\PlainAuthenticator;
use Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
/**
* Transport that will request/refresh an OAuth access token and keep EsmtpTransport behavior
*/
class SymfonyOAuthTransport extends EsmtpTransport
{
/** @var OAuthClientProviderAbstract|null */
protected static $oProvider = null;
const LOG_CHANNEL = 'OAuth';
public function __construct($aConfig = [], ?LoggerInterface $oLogger = null)
{
$sHost = '127.0.0.1';
$iPort = 25;
$sUsername = null;
$sEncryption = null;
if (is_array($aConfig)) {
if (!empty($aConfig['host'])) {
$sHost = $aConfig['host'];
}
if (!empty($aConfig['port'])) {
$iPort = (int)$aConfig['port'];
}
if (!empty($aConfig['username'])) {
$sUsername = $aConfig['username'];
}
if (!empty($aConfig['encryption'])) {
$sEncryption = $aConfig['encryption']; // 'ssl' / 'tls' / null
}
}
$bTls = is_null($sEncryption) ? null : $sEncryption === 'tls';
// Construct parent EsmtpTransport
parent::__construct($sHost, (int)$iPort, $bTls);
if ($sUsername !== null) {
$this->setUsername($sUsername);
}
// Make XOAUTH2 be attempted first, then LOGIN/PLAIN as fallback.
$this->setAuthenticators([
new XOAuth2Authenticator(),
new LoginAuthenticator(),
new PlainAuthenticator(),
]);
}
public static function setProvider(OAuthClientProviderAbstract $oProvider): void
{
self::$oProvider = $oProvider;
}
/**
* Fetch the provider if not explicitly set
*/
protected function getProvider(): OAuthClientProviderAbstract
{
if (self::$oProvider === null) {
self::$oProvider = OAuthClientProviderFactory::GetProviderForSMTP();
}
return self::$oProvider;
}
/**
* Ensure we have a fresh access token and set it as the current SMTP password*
*
* @throws IdentityProviderException
*/
protected function ensureOAuthTokenIsReady(): void
{
$oProvider = $this->getProvider();
try {
$oAccessToken = $oProvider->GetAccessToken();
}
catch (\Throwable $e) {
IssueLog::Error('Failed to get OAuth provider access token: '.$e->getMessage(), 'OAuth');
throw new TransportException('Failed to obtain OAuth access token for SMTP', 0, $e);
}
if ($oAccessToken === null) {
throw new IdentityProviderException('Not prior authentication to OAuth', 255, []);
}
elseif ($oAccessToken->hasExpired()) {
self::$oProvider->SetAccessToken(self::$oProvider->GetVendorProvider()->getAccessToken('refresh_token', [
'refresh_token' => $oAccessToken->getRefreshToken(),
'scope' => self::$oProvider->GetScope(),
]));
}
$sAccessToken = $oAccessToken->getToken();
if (empty($sAccessToken)) {
IssueLog::Error('OAuth access token is empty for outgoing mails.', 'OAuth');
throw new TransportException('OAuth access token is empty.');
}
// Set the token as the SMTP "password" as Symfony's XOAuth2Authenticator expects
$this->setPassword($sAccessToken);
}
/**
* Override send hook so we can refresh the token just before
*/
protected function doSend(SentMessage $message): void
{
// Ensure a fresh token is available and set as SMTP password
try {
$this->ensureOAuthTokenIsReady();
}
catch (IdentityProviderException $e) {
IssueLog::Error('Failed to get SMTP oAuth credentials for incoming mails for provider '.self::$oProvider::GetVendorName(), static::LOG_CHANNEL, [
'exception.message' => $e->getMessage(),
'exception.stack' => $e->getTraceAsString(),
]);
}
$sUsername = $this->getUsername();
$sAccessToken = $this->getPassword();
// Let the normal EsmtpTransport flow continue
IssueLog::Debug("SMTP OAuth trying to login in with user=$sUsername token=$sAccessToken", static::LOG_CHANNEL);
parent::doSend($message);
}
}