mirror of
https://github.com/Combodo/iTop.git
synced 2026-02-12 23:14:18 +01:00
N°7920 - laminas-mail is an abandoned package, replace it with symfony/mailer (#742)
* N°7920 - laminas-mail is an abandoned package, replace it with symfony/mailer * Fix composer following merge
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
63
sources/Core/Email/Transport/SymfonyFileTransport.php
Normal file
63
sources/Core/Email/Transport/SymfonyFileTransport.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
142
sources/Core/Email/Transport/SymfonyOAuthTransport.php
Normal file
142
sources/Core/Email/Transport/SymfonyOAuthTransport.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user