Spaces:
No application file
No application file
namespace Mautic\EmailBundle\Tests\Model; | |
use Doctrine\ORM\EntityManager; | |
use Mautic\CoreBundle\Factory\MauticFactory; | |
use Mautic\CoreBundle\Helper\CoreParametersHelper; | |
use Mautic\EmailBundle\Entity\CopyRepository; | |
use Mautic\EmailBundle\Entity\Email; | |
use Mautic\EmailBundle\Entity\Stat; | |
use Mautic\EmailBundle\Event\EmailSendEvent; | |
use Mautic\EmailBundle\Exception\FailedToSendToContactException; | |
use Mautic\EmailBundle\Helper\DTO\AddressDTO; | |
use Mautic\EmailBundle\Helper\FromEmailHelper; | |
use Mautic\EmailBundle\Helper\MailHashHelper; | |
use Mautic\EmailBundle\Helper\MailHelper; | |
use Mautic\EmailBundle\Model\EmailModel; | |
use Mautic\EmailBundle\Model\EmailStatModel; | |
use Mautic\EmailBundle\Model\SendEmailToContact; | |
use Mautic\EmailBundle\MonitoredEmail\Mailbox; | |
use Mautic\EmailBundle\Stat\StatHelper; | |
use Mautic\EmailBundle\Tests\Helper\Transport\BatchTransport; | |
use Mautic\LeadBundle\Entity\Lead; | |
use Mautic\LeadBundle\Model\DoNotContact; | |
use PHPUnit\Framework\MockObject\MockObject; | |
use Psr\Log\LoggerInterface; | |
use Psr\Log\NullLogger; | |
use Symfony\Component\EventDispatcher\EventDispatcher; | |
use Symfony\Component\Mailer\Mailer; | |
use Symfony\Component\Routing\Router; | |
use Symfony\Component\Routing\RouterInterface; | |
use Symfony\Contracts\Translation\TranslatorInterface; | |
class SendEmailToContactTest extends \PHPUnit\Framework\TestCase | |
{ | |
/** | |
* @var array<array<string,int|string>> | |
*/ | |
private array $contacts = [ | |
[ | |
'id' => 1, | |
'email' => '[email protected]', | |
'firstname' => 'Contact', | |
'lastname' => '1', | |
'owner_id' => 1, | |
], | |
[ | |
'id' => 2, | |
'email' => '[email protected]', | |
'firstname' => 'Contact', | |
'lastname' => '2', | |
'owner_id' => 0, | |
], | |
[ | |
'id' => 3, | |
'email' => '[email protected]', | |
'firstname' => 'Contact', | |
'lastname' => '3', | |
'owner_id' => 2, | |
], | |
[ | |
'id' => 4, | |
'email' => '[email protected]', | |
'firstname' => 'Contact', | |
'lastname' => '4', | |
'owner_id' => 1, | |
], | |
]; | |
/** | |
* @var MockObject&FromEmailHelper | |
*/ | |
private $fromEmaiHelper; | |
/** | |
* @var MockObject&CoreParametersHelper | |
*/ | |
private $coreParametersHelper; | |
/** | |
* @var MockObject&Mailbox | |
*/ | |
private $mailbox; | |
/** | |
* @var MockObject&LoggerInterface | |
*/ | |
private MockObject $loggerMock; | |
private MailHashHelper $mailHashHelper; | |
/** | |
* @var MockObject&TranslatorInterface | |
*/ | |
private MockObject $translator; | |
/** | |
* @var MockObject|MailHelper | |
*/ | |
private $mailHelper; | |
/** | |
* @var MockObject|DoNotContact | |
*/ | |
private $dncModel; | |
/** | |
* @var MockObject|EmailStatModel | |
*/ | |
private $emailStatModel; | |
private StatHelper $statHelper; | |
protected function setUp(): void | |
{ | |
parent::setUp(); | |
$this->dncModel = $this->createMock(DoNotContact::class); | |
$this->mailHelper = $this->createMock(MailHelper::class); | |
$this->emailStatModel = $this->createMock(EmailStatModel::class); | |
$this->statHelper = new StatHelper($this->emailStatModel); | |
$this->fromEmaiHelper = $this->createMock(FromEmailHelper::class); | |
$this->coreParametersHelper = $this->createMock(CoreParametersHelper::class); | |
$this->mailbox = $this->createMock(Mailbox::class); | |
$this->loggerMock = $this->createMock(LoggerInterface::class); | |
$this->mailHashHelper = new MailHashHelper($this->coreParametersHelper); | |
$this->translator = $this->createMock(TranslatorInterface::class); | |
} | |
/** | |
* @testdox Tests that all contacts are temporarily failed if an Email entity happens to be incorrectly configured | |
*/ | |
public function testContactsAreFailedIfSettingEmailEntityFails(): void | |
{ | |
$this->mailHelper->method('setEmail') | |
->willReturn(false); | |
// This should not be called because contact emails are just fine; the problem is with the email entity | |
$this->dncModel->expects($this->never()) | |
->method('addDncForContact'); | |
$model = new SendEmailToContact($this->mailHelper, $this->statHelper, $this->dncModel, $this->translator); | |
$email = new Email(); | |
$model->setEmail($email); | |
foreach ($this->contacts as $contact) { | |
try { | |
$model->setContact($contact) | |
->send(); | |
} catch (FailedToSendToContactException) { | |
} | |
} | |
$model->finalFlush(); | |
$failedContacts = $model->getFailedContacts(); | |
$this->assertCount(4, $failedContacts); | |
} | |
/** | |
* @testdox Tests that bad emails are failed | |
*/ | |
public function testExceptionIsThrownIfEmailIsSentToBadContact(): void | |
{ | |
$emailMock = $this->getMockBuilder(Email::class) | |
->getMock(); | |
$emailMock | |
->expects($this->any()) | |
->method('getId') | |
->will($this->returnValue(1)); | |
$this->mailHelper->method('setEmail') | |
->willReturn(true); | |
$this->mailHelper->method('addTo') | |
->willReturnCallback( | |
fn ($email) => '@bad.com' !== $email | |
); | |
$this->mailHelper->method('queue') | |
->willReturn([true, []]); | |
$stat = new Stat(); | |
$stat->setEmail($emailMock); | |
$this->mailHelper->method('createEmailStat') | |
->willReturn($stat); | |
$this->dncModel->expects($this->once()) | |
->method('addDncForContact'); | |
$model = new SendEmailToContact($this->mailHelper, $this->statHelper, $this->dncModel, $this->translator); | |
$model->setEmail($emailMock); | |
$contacts = $this->contacts; | |
$contacts[0]['email'] = '@bad.com'; | |
$exceptionThrown = false; | |
foreach ($contacts as $contact) { | |
try { | |
$model->setContact($contact) | |
->send(); | |
} catch (FailedToSendToContactException) { | |
$exceptionThrown = true; | |
} | |
} | |
if (!$exceptionThrown) { | |
$this->fail('FailedToSendToContactException not thrown'); | |
} | |
$model->finalFlush(); | |
$failedContacts = $model->getFailedContacts(); | |
$this->assertCount(1, $failedContacts); | |
} | |
/** | |
* @testdox Test a tokenized transport that limits batches does not throw BatchQueueMaxException on subsequent contacts when one fails | |
*/ | |
public function testBadEmailDoesNotCauseBatchQueueMaxExceptionOnSubsequentContacts(): void | |
{ | |
$emailMock = $this->createMock(Email::class); | |
$emailMock->method('getId')->will($this->returnValue(1)); | |
$emailMock->method('getFromAddress')->willReturn('[email protected]'); | |
$emailMock->method('getSubject')->willReturn('Subject'); | |
$emailMock->method('getCustomHtml')->willReturn('content'); | |
// Use our test token transport limiting to 1 recipient per queue | |
$transport = new BatchTransport(false, 1); | |
$mailer = new Mailer($transport); | |
// Mock factory to ensure that queue mode is handled until MailHelper is refactored completely away from MauticFactory | |
$factoryMock = $this->createMock(MauticFactory::class); | |
$factoryMock->method('getParameter') | |
->willReturnCallback( | |
fn ($param) => match ($param) { | |
default => '', | |
} | |
); | |
$factoryMock->method('getLogger') | |
->willReturn( | |
new NullLogger() | |
); | |
$factoryMock->method('getDispatcher') | |
->willReturn( | |
new EventDispatcher() | |
); | |
$routerMock = $this->createMock(Router::class); | |
$this->fromEmaiHelper->method('getFromAddressConsideringOwner') | |
->willReturn(new AddressDTO('[email protected]')); | |
$this->coreParametersHelper->method('get')->will($this->returnValueMap([['mailer_from_email', null, '[email protected]'], ['secret_key', null, 'secret']])); | |
$mailHelper = $this->getMockBuilder(MailHelper::class) | |
->setConstructorArgs([$factoryMock, $mailer, $this->fromEmaiHelper, $this->coreParametersHelper, $this->mailbox, $this->loggerMock, $this->mailHashHelper, $routerMock]) | |
->onlyMethods(['createEmailStat']) | |
->getMock(); | |
$mailHelper->method('createEmailStat') | |
->willReturnCallback( | |
function () use ($emailMock) { | |
$stat = new Stat(); | |
$stat->setEmail($emailMock); | |
$leadMock = $this->getMockBuilder(Lead::class) | |
->getMock(); | |
$leadMock->method('getId') | |
->willReturn(1); | |
$stat->setLead($leadMock); | |
return $stat; | |
} | |
); | |
// Enable queueing | |
$mailHelper->enableQueue(); | |
$this->dncModel->expects($this->exactly(1)) | |
->method('addDncForContact'); | |
$model = new SendEmailToContact($mailHelper, $this->statHelper, $this->dncModel, $this->translator); | |
$model->setEmail($emailMock); | |
$contacts = $this->contacts; | |
$contacts[0]['email'] = '@bad.com'; | |
foreach ($contacts as $contact) { | |
try { | |
$model->setContact($contact) | |
->send(); | |
} catch (FailedToSendToContactException) { | |
// We're good here | |
} | |
} | |
$model->finalFlush(); | |
$failedContacts = $model->getFailedContacts(); | |
$this->assertCount(1, $failedContacts); | |
// Our fake transport should have processed 3 metadatas | |
$this->assertCount(3, $transport->getMetadatas()); | |
// We made it this far so all of the emails were processed despite a bad email in the batch | |
} | |
/** | |
* @testdox Test a tokenized transport that fills tokens correctly | |
*/ | |
public function testBatchQueueContactsHaveTokensHydrated(): void | |
{ | |
$this->coreParametersHelper->method('get')->will($this->returnValueMap([['mailer_from_email', null, '[email protected]'], ['secret_key', null, 'secret']])); | |
$emailMock = $this->createMock(Email::class); | |
$emailMock->method('getId')->will($this->returnValue(1)); | |
$emailMock->method('getFromAddress')->willReturn('[email protected]'); | |
$emailMock->method('getSubject')->willReturn('Subject'); | |
$emailMock->method('getCustomHtml')->willReturn('Hi {contactfield=firstname}'); | |
// Use our test token transport limiting to 1 recipient per queue | |
$transport = new BatchTransport(false, 1); | |
$mailer = new Mailer($transport); | |
// Mock factory to ensure that queue mode is handled until MailHelper is refactored completely away from MauticFactory | |
$factoryMock = $this->createMock(MauticFactory::class); | |
$factoryMock->method('getParameter') | |
->willReturnCallback( | |
fn ($param) => match ($param) { | |
default => '', | |
} | |
); | |
$factoryMock->method('getLogger') | |
->willReturn( | |
new NullLogger() | |
); | |
$mockEm = $this->createMock(EntityManager::class); | |
$factoryMock->method('getEntityManager') | |
->willReturn($mockEm); | |
$mockDispatcher = $this->getMockBuilder(EventDispatcher::class) | |
->getMock(); | |
$mockDispatcher->method('dispatch') | |
->willReturnCallback( | |
function (EmailSendEvent $event, $eventName) { | |
$lead = $event->getLead(); | |
$tokens = []; | |
foreach ($lead as $field => $value) { | |
$tokens["{contactfield=$field}"] = $value; | |
} | |
$tokens['{hash}'] = $event->getIdHash(); | |
$event->addTokens($tokens); | |
return $event; | |
} | |
); | |
$factoryMock->method('getDispatcher') | |
->willReturn($mockDispatcher); | |
$routerMock = $this->createMock(Router::class); | |
$copyRepoMock = $this->createMock(CopyRepository::class); | |
$emailModelMock = $this->createMock(EmailModel::class); | |
$emailModelMock->method('getCopyRepository') | |
->willReturn($copyRepoMock); | |
$factoryMock->method('getModel') | |
->willReturn($emailModelMock); | |
$this->fromEmaiHelper->method('getFromAddressConsideringOwner') | |
->willReturn(new AddressDTO('[email protected]')); | |
$mailHelper = $this->getMockBuilder(MailHelper::class) | |
->setConstructorArgs([$factoryMock, $mailer, $this->fromEmaiHelper, $this->coreParametersHelper, $this->mailbox, $this->loggerMock, $this->mailHashHelper, $routerMock]) | |
->onlyMethods([]) | |
->getMock(); | |
// Enable queueing | |
$mailHelper->enableQueue(); | |
$this->emailStatModel->method('saveEntity') | |
->willReturnCallback( | |
function (Stat $stat): void { | |
$tokens = $stat->getTokens(); | |
$this->assertGreaterThan(1, count($tokens)); | |
$this->assertEquals($stat->getTrackingHash(), $tokens['{hash}']); | |
} | |
); | |
$model = new SendEmailToContact($mailHelper, $this->statHelper, $this->dncModel, $this->translator); | |
$model->setEmail($emailMock); | |
foreach ($this->contacts as $contact) { | |
try { | |
$model->setContact($contact) | |
->send(); | |
} catch (FailedToSendToContactException) { | |
// We're good here | |
} | |
} | |
$model->finalFlush(); | |
$this->assertCount(4, $transport->getMetadatas()); | |
} | |
/** | |
* @testdox Test that stat entries are saved in batches of 20 | |
*/ | |
public function testThatStatEntriesAreCreatedAndPersistedEveryBatch(): void | |
{ | |
$this->coreParametersHelper->method('get')->will($this->returnValueMap([['mailer_from_email', null, '[email protected]'], ['secret_key', null, 'secret']])); | |
$emailMock = $this->createMock(Email::class); | |
$emailMock->method('getId')->willReturn(1); | |
$emailMock->method('getFromAddress')->willReturn('[email protected]'); | |
$emailMock->method('getSubject')->willReturn('Subject'); | |
$emailMock->method('getCustomHtml')->willReturn('content'); | |
// Use our test token transport limiting to 1 recipient per queue | |
$transport = new BatchTransport(false, 1); | |
$mailer = new Mailer($transport); | |
// Mock factory to ensure that queue mode is handled until MailHelper is refactored completely away from MauticFactory | |
$factoryMock = $this->createMock(MauticFactory::class); | |
$factoryMock->method('getParameter') | |
->willReturnCallback( | |
fn ($param) => match ($param) { | |
default => '', | |
} | |
); | |
$factoryMock->method('getLogger') | |
->willReturn( | |
new NullLogger() | |
); | |
$factoryMock->method('getDispatcher') | |
->willReturn( | |
new EventDispatcher() | |
); | |
$routerMock = $this->createMock(Router::class); | |
$this->fromEmaiHelper->method('getFromAddressConsideringOwner') | |
->willReturn(new AddressDTO('[email protected]')); | |
$mailHelper = $this->getMockBuilder(MailHelper::class) | |
->setConstructorArgs([$factoryMock, $mailer, $this->fromEmaiHelper, $this->coreParametersHelper, $this->mailbox, $this->loggerMock, $this->mailHashHelper, $routerMock]) | |
->onlyMethods(['createEmailStat']) | |
->getMock(); | |
$mailHelper->expects($this->exactly(21)) | |
->method('createEmailStat') | |
->willReturnCallback( | |
function () use ($emailMock) { | |
$stat = new Stat(); | |
$stat->setEmail($emailMock); | |
$leadMock = $this->getMockBuilder(Lead::class) | |
->getMock(); | |
$leadMock->method('getId') | |
->willReturn(1); | |
$stat->setLead($leadMock); | |
return $stat; | |
} | |
); | |
// Enable queueing | |
$mailHelper->enableQueue(); | |
// Here's the test; this should be called after 20 contacts are processed | |
$this->emailStatModel->expects($this->exactly(21)) | |
->method('saveEntity'); | |
$this->dncModel->expects($this->never()) | |
->method('addDncForContact'); | |
$model = new SendEmailToContact($mailHelper, $this->statHelper, $this->dncModel, $this->translator); | |
$model->setEmail($emailMock); | |
// Let's generate 20 bogus contacts | |
$contacts = []; | |
$counter = 0; | |
while ($counter <= 20) { | |
$contacts[] = [ | |
'id' => $counter, | |
'email' => 'email'.uniqid().'@somewhere.com', | |
'firstname' => 'Contact', | |
'lastname' => uniqid(), | |
]; | |
++$counter; | |
} | |
foreach ($contacts as $contact) { | |
try { | |
$model->setContact($contact) | |
->send(); | |
} catch (FailedToSendToContactException $exception) { | |
$this->fail('FailedToSendToContactException thrown: '.$exception->getMessage()); | |
} | |
} | |
$model->finalFlush(); | |
$failedContacts = $model->getFailedContacts(); | |
$this->assertCount(0, $failedContacts); | |
$this->assertCount(21, $transport->getMetadatas()); | |
} | |
/** | |
* @testdox Test that a failed email from the transport is handled | |
*/ | |
public function testThatAFailureFromTransportIsHandled(): void | |
{ | |
$this->coreParametersHelper->method('get')->will($this->returnValueMap([['mailer_from_email', null, '[email protected]'], ['secret_key', null, 'secret']])); | |
$emailMock = $this->createMock(Email::class); | |
$emailMock->method('getId')->willReturn(1); | |
$emailMock->method('getFromAddress')->willReturn('[email protected]'); | |
$emailMock->method('getSubject')->willReturn(''); // The subject must be empty for the email to fail. | |
$emailMock->method('getCustomHtml')->willReturn('content'); | |
// Use our test token transport limiting to 1 recipient per queue | |
$transport = new BatchTransport(true, 1); | |
$mailer = new Mailer($transport); | |
// Mock factory to ensure that queue mode is handled until MailHelper is refactored completely away from MauticFactory | |
$factoryMock = $this->createMock(MauticFactory::class); | |
$factoryMock->method('getParameter') | |
->willReturnCallback( | |
fn ($param) => match ($param) { | |
default => '', | |
} | |
); | |
$this->fromEmaiHelper->method('getFromAddressConsideringOwner')->willReturn(new AddressDTO('[email protected]')); | |
$factoryMock->method('getLogger')->willReturn(new NullLogger()); | |
$factoryMock->method('getDispatcher')->willReturn(new EventDispatcher()); | |
$routerMock = $this->createMock(Router::class); | |
/** @var MockObject&MailHelper $mailHelper */ | |
$mailHelper = $this->getMockBuilder(MailHelper::class) | |
->setConstructorArgs([$factoryMock, $mailer, $this->fromEmaiHelper, $this->coreParametersHelper, $this->mailbox, $this->loggerMock, $this->mailHashHelper, $routerMock]) | |
->onlyMethods(['createEmailStat']) | |
->getMock(); | |
$mailHelper->method('createEmailStat') | |
->willReturnCallback( | |
function () use ($emailMock) { | |
$stat = new Stat(); | |
$stat->setEmail($emailMock); | |
$leadMock = $this->createMock(Lead::class); | |
$leadMock->method('getId')->willReturn(1); | |
$stat->setLead($leadMock); | |
return $stat; | |
} | |
); | |
// Enable queueing | |
$mailHelper->enableQueue(); | |
$this->dncModel->expects($this->never())->method('addDncForContact'); | |
$model = new SendEmailToContact($mailHelper, $this->statHelper, $this->dncModel, $this->translator); | |
$model->setEmail($emailMock); | |
foreach ($this->contacts as $contact) { | |
try { | |
$model->setContact($contact)->send(); | |
} catch (FailedToSendToContactException) { | |
// We're good here | |
} | |
} | |
$model->finalFlush(); | |
$failedContacts = $model->getFailedContacts(); | |
$this->assertCount(1, $failedContacts); | |
$counts = $model->getSentCounts(); | |
// Should have increased to 4, one failed via the transport so back down to 3 | |
$this->assertEquals(3, $counts[1]); | |
// One error message from the transport | |
$errorMessages = $model->getErrors(); | |
$this->assertCount(1, $errorMessages); | |
} | |
/** | |
* @testdox Test that sending an email with invalid Bcc address is handled | |
* | |
* @covers \Mautic\EmailBundle\Model\SendEmailToContact::setContact() | |
* @covers \Mautic\EmailBundle\Model\SendEmailToContact::send() | |
* @covers \Mautic\EmailBundle\Model\SendEmailToContact::failContact() | |
*/ | |
public function testThatInvalidBccFailureIsHandled(): void | |
{ | |
defined('MAUTIC_ENV') or define('MAUTIC_ENV', 'test'); | |
/** @var MockObject&MauticFactory $mockFactory */ | |
$mockFactory = $this->createMock(MauticFactory::class); | |
$mockFactory->method('getParameter') | |
->will( | |
$this->returnValueMap( | |
[ | |
['mailer_return_path', false, null], | |
] | |
) | |
); | |
$mockFactory->method('getLogger')->willReturn(new NullLogger()); | |
/** @var MockObject&FromEmailHelper $fromEmailHelper */ | |
$fromEmailHelper = $this->createMock(FromEmailHelper::class); | |
/** @var MockObject&CoreParametersHelper $coreParametersHelper */ | |
$coreParametersHelper = $this->createMock(CoreParametersHelper::class); | |
/** @var MockObject&Mailbox $mailbox */ | |
$mailbox = $this->createMock(Mailbox::class); | |
/** @var MockObject&LoggerInterface $logger */ | |
$logger = $this->createMock(LoggerInterface::class); | |
/** @var MockObject&RouterInterface $router */ | |
$router = $this->createMock(RouterInterface::class); | |
$coreParametersHelper->method('get') | |
->willReturnMap( | |
[ | |
['mailer_from_email', null, '[email protected]'], | |
['mailer_from_name', null, 'No Body'], | |
] | |
); | |
$mailer = new Mailer(new BatchTransport()); | |
$mailHelper = new MailHelper($mockFactory, $mailer, $fromEmailHelper, $coreParametersHelper, $mailbox, $logger, $this->mailHashHelper, $router); | |
$dncModel = $this->createMock(DoNotContact::class); | |
$translator = $this->createMock(TranslatorInterface::class); | |
$model = new SendEmailToContact($mailHelper, $this->statHelper, $dncModel, $translator); | |
$emailMock = $this->createMock(Email::class); | |
$emailMock->method('getId')->willReturn(1); | |
$emailMock->method('getSubject')->willReturn('subject'); | |
$emailMock->method('getCustomHtml')->willReturn('content'); | |
// Set invalid BCC (should use comma as separator) | |
$emailMock | |
->expects($this->any()) | |
->method('getBccAddress') | |
->willReturn('[email protected]; [email protected]'); | |
$model->setEmail($emailMock); | |
$stat = new Stat(); | |
$stat->setEmail($emailMock); | |
$this->expectException(FailedToSendToContactException::class); | |
$this->expectExceptionMessage('Email "[email protected]; [email protected]" does not comply with addr-spec of RFC 2822.'); | |
// Send should trigger the FailedToSendToContactException | |
$model->setContact($this->contacts[0])->send(); | |
} | |
} | |