Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Model;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\EmailRepository;
class EmailActionModel
{
public function __construct(
private EmailModel $emailModel,
private EmailRepository $emailRepository,
private CorePermissions $corePermissions,
) {
}
/**
* @param array<int> $emailsIds
*
* @return array<Email>
*/
public function setCategory(array $emailsIds, Category $newCategory): array
{
$emails = $this->emailRepository->findBy(['id' => $emailsIds]);
$affected = [];
foreach ($emails as $email) {
if (!$this->canEdit($email)) {
continue;
}
$email->setCategory($newCategory);
$affected[] = $email;
}
if ($affected) {
$this->saveEntities($emails);
}
return $affected;
}
private function canEdit(Email $email): bool
{
return $this->corePermissions->hasEntityAccess('email:emails:editown', 'email:emails:editother', $email->getCreatedBy());
}
/**
* @param array<Email> $emails
*/
private function saveEntities(array $emails): void
{
$this->emailModel->saveEntities($emails);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Model;
use Mautic\CoreBundle\Model\AbstractCommonModel;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\EmailDraft;
use Mautic\EmailBundle\Entity\EmailDraftRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EmailDraftModel extends AbstractCommonModel
{
public function createDraft(Email $email, string $html, string $template, bool $publicPreview = true): EmailDraft
{
$emailDraft = $this->getRepository()->findOneBy(['email' => $email]);
if (!is_null($emailDraft)) {
throw new \Exception(sprintf('Draft already exists for email %d', $email->getId()));
}
$emailDraft = new EmailDraft($email, $html, $template, $publicPreview);
$this->em->persist($emailDraft);
$this->em->flush();
return $emailDraft;
}
public function deleteDraft(Email $email): void
{
if (is_null($emailDraft = $email->getDraft())) {
throw new NotFoundHttpException(sprintf('Draft not found for email %d', $email->getId()));
}
$this->em->remove($emailDraft);
$this->em->flush();
}
public function getPermissionBase(): string
{
return 'email:emails';
}
public function getRepository(): EmailDraftRepository
{
return $this->em->getRepository(EmailDraft::class);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\EmailBundle\Event\EmailStatEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class EmailStatModel
{
public function __construct(private EntityManagerInterface $entityManager, private EventDispatcherInterface $dispatcher)
{
}
public function saveEntity(Stat $stat): void
{
$this->saveEntities([$stat]);
}
/**
* @param Stat[] $stats
*/
public function saveEntities(array $stats): void
{
$event = new EmailStatEvent($stats);
$this->dispatcher->dispatch($event, EmailEvents::ON_EMAIL_STAT_PRE_SAVE);
$this->getRepository()->saveEntities($stats);
$this->dispatcher->dispatch($event, EmailEvents::ON_EMAIL_STAT_POST_SAVE);
}
public function getRepository(): StatRepository
{
return $this->entityManager->getRepository(Stat::class);
}
}

View File

@@ -0,0 +1,361 @@
<?php
namespace Mautic\EmailBundle\Model;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Exception\FailedToSendToContactException;
use Mautic\EmailBundle\Helper\MailHelper;
use Mautic\EmailBundle\Mailer\Exception\BatchQueueMaxException;
use Mautic\EmailBundle\Stat\Exception\StatNotFoundException;
use Mautic\EmailBundle\Stat\Reference;
use Mautic\EmailBundle\Stat\StatHelper;
use Mautic\LeadBundle\Entity\DoNotContact as DNC;
use Mautic\LeadBundle\Model\DoNotContact;
use Symfony\Contracts\Translation\TranslatorInterface;
class SendEmailToContact
{
private array $failedContacts = [];
private array $errorMessages = [];
private array $badEmails = [];
private array $emailSentCounts = [];
/**
* @var array|null
*/
private $emailEntityErrors;
/**
* @var int|null
*/
private $emailEntityId;
private ?int $listId = null;
private int $statBatchCounter = 0;
private array $contact = [];
public function __construct(
private MailHelper $mailer,
private StatHelper $statHelper,
private DoNotContact $dncModel,
private TranslatorInterface $translator,
) {
}
/**
* @param bool $resetMailer
*
* @return $this
*/
public function flush($resetMailer = true)
{
// Flushes the batch in case of using API mailers
if ($this->emailEntityId && !$flushResult = $this->mailer->flushQueue()) {
$sendFailures = $this->mailer->getErrors();
// Check to see if failed recipients were stored by the transport
if (!empty($sendFailures['failures'])) {
$this->processSendFailures($sendFailures);
}
}
if ($resetMailer) {
$this->mailer->reset(true);
}
return $this;
}
/**
* Flush any remaining queued contacts, process spending stats, create DNC entries and reset this class.
*/
public function finalFlush(): void
{
$this->flush();
$this->statHelper->deletePending();
$this->statHelper->reset();
$this->processBadEmails();
}
/**
* Use an Email entity to populate content, from, etc.
*
* @param array $channel ['channelName', 'channelId']
*
* @return $this
*/
public function setEmail(Email $email, array $channel = [], array $customHeaders = [], array $assetAttachments = [], ?string $emailType = null)
{
// Flush anything that's pending from a previous email
$this->flush();
// Enable the queue if applicable to the transport
$this->mailer->enableQueue();
if ($this->mailer->setEmail($email, true, $assetAttachments)) {
$this->mailer->setEmailType($emailType);
$this->mailer->setSource($channel);
$this->mailer->setCustomHeaders($customHeaders);
// Note that the entity is set so that addContact does not generate errors
$this->emailEntityId = $email->getId();
} else {
// Fail all the contacts in this batch
$this->emailEntityErrors = $this->mailer->getErrors();
$this->emailEntityId = null;
}
return $this;
}
/**
* @param int|null $id
*
* @return $this
*/
public function setListId($id)
{
$this->listId = empty($id) ? null : (int) $id;
return $this;
}
/**
* @return $this
*
* @throws FailedToSendToContactException
*/
public function setContact(array $contact, array $tokens = [])
{
$this->contact = $contact;
if (!$this->emailEntityId) {
// There was an error configuring the email so auto fail
$this->failContact(false, $this->emailEntityErrors);
}
$this->mailer->setTokens($tokens);
$this->mailer->setLead($contact);
$this->mailer->setIdHash(); // auto generates
try {
if (!$this->mailer->addTo($contact['email'], $contact['firstname'].' '.$contact['lastname'])) {
$this->failContact();
}
} catch (BatchQueueMaxException) {
// Queue full so flush then try again
$this->flush(false);
if (!$this->mailer->addTo($contact['email'], $contact['firstname'].' '.$contact['lastname'])) {
$this->failContact();
}
}
return $this;
}
/**
* @throws FailedToSendToContactException
*/
public function send(): void
{
if ($this->mailer->inTokenizationMode()) {
[$success, $errors] = $this->queueTokenizedEmail();
} else {
[$success, $errors] = $this->sendStandardEmail();
}
// queue or send the message
if (!$success) {
unset($errors['failures']);
$this->failContact(false, implode('; ', (array) $errors));
}
}
/**
* Reset everything.
*/
public function reset(): void
{
$this->badEmails = [];
$this->errorMessages = [];
$this->failedContacts = [];
$this->emailEntityErrors = null;
$this->emailEntityId = null;
$this->emailSentCounts = [];
$this->listId = null;
$this->statBatchCounter = 0;
$this->contact = [];
$this->mailer->reset();
}
/**
* @return array
*/
public function getSentCounts()
{
return $this->emailSentCounts;
}
/**
* @return array
*/
public function getErrors()
{
return $this->errorMessages;
}
/**
* @return array
*/
public function getFailedContacts()
{
return $this->failedContacts;
}
/**
* @param bool $hasBadEmail
* @param array $errorMessages
*
* @throws FailedToSendToContactException
*/
protected function failContact($hasBadEmail = true, $errorMessages = null)
{
if (null === $errorMessages) {
// Clear the errors so it doesn't stop the next send
$errorMessages = implode('; ', (array) $this->mailer->getErrors());
} elseif (is_array($errorMessages)) {
$errorMessages = implode('; ', $errorMessages);
}
$this->errorMessages[$this->contact['id']] = $errorMessages;
$this->failedContacts[$this->contact['id']] = $this->contact['email'];
try {
$stat = $this->statHelper->getStat($this->contact['email']);
$this->downEmailSentCount($stat->getEmailId());
$this->statHelper->markForDeletion($stat);
} catch (StatNotFoundException) {
}
if ($hasBadEmail) {
$this->badEmails[$this->contact['id']] = $this->contact['email'];
}
throw new FailedToSendToContactException($errorMessages);
}
protected function processSendFailures($sendFailures)
{
$failedEmailAddresses = $sendFailures['failures'];
unset($sendFailures['failures']);
$error = implode('; ', $sendFailures);
// Delete the stat
foreach ($failedEmailAddresses as $failedEmail) {
try {
/** @var Reference $stat */
$stat = $this->statHelper->getStat($failedEmail);
} catch (StatNotFoundException) {
continue;
}
// Add lead ID to list of failures
$this->failedContacts[$stat->getLeadId()] = $failedEmail;
$this->errorMessages[$stat->getLeadId()] = $error;
$this->statHelper->markForDeletion($stat);
// Down sent counts
$this->downEmailSentCount($stat->getEmailId());
}
}
/**
* Add DNC entries for bad emails to get them out of the queue permanently.
*/
protected function processBadEmails()
{
// Update bad emails as bounces
if (count($this->badEmails)) {
foreach ($this->badEmails as $contactId => $contactEmail) {
$this->dncModel->addDncForContact(
$contactId,
['email' => $this->emailEntityId],
DNC::BOUNCED,
$this->translator->trans('mautic.email.bounce.reason.bad_email'),
true,
false
);
}
}
}
protected function createContactStatEntry($email)
{
++$this->statBatchCounter;
$stat = $this->mailer->createEmailStat(false, null, $this->listId);
// Store it in the statEntities array so that the stat can be deleted if the transport fails the
// send for whatever reason after flushing the queue
$this->statHelper->storeStat($stat, $email);
$this->upEmailSentCount($stat->getEmail()->getId());
}
/**
* Up sent counter for the given email ID.
*/
protected function upEmailSentCount($emailId)
{
// Up sent counts
if (!isset($this->emailSentCounts[$emailId])) {
$this->emailSentCounts[$emailId] = 0;
}
++$this->emailSentCounts[$emailId];
}
/**
* Down sent counter for the given email ID.
*/
protected function downEmailSentCount($emailId)
{
--$this->emailSentCounts[$emailId];
}
protected function queueTokenizedEmail(): array
{
[$queued, $queueErrors] = $this->mailer->queue(true, MailHelper::QUEUE_RETURN_ERRORS);
if ($queued) {
// Create stat first to ensure it is available for emails sent immediately
$this->createContactStatEntry($this->contact['email']);
}
return [$queued, $queueErrors];
}
/**
* @return array
*/
protected function sendStandardEmail()
{
// Dispatch the event to generate the tokens
$this->mailer->dispatchSendEvent();
// Create the stat to ensure it is available for emails sent
$this->createContactStatEntry($this->contact['email']);
// Now send but don't redispatch the event
return $this->mailer->queue(false, MailHelper::QUEUE_RETURN_ERRORS);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Mautic\EmailBundle\Model;
use Doctrine\ORM\ORMException;
use Mautic\CoreBundle\Event\TokenReplacementEvent;
use Mautic\CoreBundle\Exception\InvalidValueException;
use Mautic\CoreBundle\Exception\RecordException;
use Mautic\CoreBundle\Helper\ArrayHelper;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Exception\EmailCouldNotBeSentException;
use Mautic\EmailBundle\Exception\InvalidEmailException;
use Mautic\EmailBundle\Helper\EmailValidator;
use Mautic\EmailBundle\OptionsAccessor\EmailToUserAccessor;
use Mautic\LeadBundle\DataObject\ContactFieldToken;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Exception\InvalidContactFieldTokenException;
use Mautic\LeadBundle\Validator\CustomFieldValidator;
use Mautic\UserBundle\Hash\UserHash;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class SendEmailToUser
{
public function __construct(
private EmailModel $emailModel,
private EventDispatcherInterface $dispatcher,
private CustomFieldValidator $customFieldValidator,
private EmailValidator $emailValidator,
) {
}
/**
* @throws EmailCouldNotBeSentException
* @throws ORMException
*/
public function sendEmailToUsers(array $config, Lead $lead): void
{
$emailToUserAccessor = new EmailToUserAccessor($config);
$email = $this->emailModel->getEntity($emailToUserAccessor->getEmailID());
if (!$email || !$email->isPublished()) {
throw new EmailCouldNotBeSentException('Email not found or published');
}
$leadCredentials = $lead->getProfileFields();
$to = ArrayHelper::removeEmptyValues($this->replaceTokens($emailToUserAccessor->getToFormatted(), $lead));
$cc = ArrayHelper::removeEmptyValues($this->replaceTokens($emailToUserAccessor->getCcFormatted(), $lead));
$bcc = ArrayHelper::removeEmptyValues($this->replaceTokens($emailToUserAccessor->getBccFormatted(), $lead));
$users = $emailToUserAccessor->getUserIdsToSend($lead->getOwner());
$idHash = UserHash::getFakeUserHash();
$tokens = $this->emailModel->dispatchEmailSendEvent($email, $leadCredentials, $idHash)->getTokens();
$errors = $this->emailModel->sendEmailToUser($email, $users, $leadCredentials, $tokens, [], false, $to, $cc, $bcc);
if ($errors) {
throw new EmailCouldNotBeSentException(implode(', ', $errors));
}
}
/**
* @param string[] $emailAddressesOrTokens
*
* @return string[]
*/
private function replaceTokens(array $emailAddressesOrTokens, Lead $lead): array
{
return array_map($this->makeTokenReplacerCallback($lead), $emailAddressesOrTokens);
}
private function makeTokenReplacerCallback(Lead $lead): callable
{
return function (string $emailAddressOrToken) use ($lead): string {
try {
$contactFieldToken = new ContactFieldToken($emailAddressOrToken);
} catch (InvalidContactFieldTokenException) {
try {
$this->emailValidator->validate($emailAddressOrToken);
return $emailAddressOrToken;
} catch (InvalidEmailException) {
return '';
}
}
// The values are validated on form save.
// But ensure the custom field is still valid on email send before asking for the replacement value.
try {
// Validate that the contact field exists and is type of email.
$this->customFieldValidator->validateFieldType($contactFieldToken->getFieldAlias(), 'email');
return $this->replaceToken($contactFieldToken->getFullToken(), $lead);
} catch (InvalidValueException|RecordException) {
// If the field does not exist or is not type of email then use the default value.
return (string) $contactFieldToken->getDefaultValue();
}
};
}
private function replaceToken(string $token, Lead $lead): string
{
$tokenEvent = new TokenReplacementEvent($token, $lead);
$this->dispatcher->dispatch($tokenEvent, EmailEvents::ON_EMAIL_ADDRESS_TOKEN_REPLACEMENT);
return $tokenEvent->getContent();
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Mautic\EmailBundle\Model;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\MonitoredEmail\Search\ContactFinder;
use Mautic\LeadBundle\Entity\DoNotContact as DNC;
use Mautic\LeadBundle\Model\DoNotContact;
class TransportCallback
{
public function __construct(
private DoNotContact $dncModel,
private ContactFinder $finder,
private EmailStatModel $emailStatModel,
) {
}
/**
* @param string $hashId
* @param string $comments
* @param int $dncReason
*/
public function addFailureByHashId($hashId, $comments, $dncReason = DNC::BOUNCED): void
{
$result = $this->finder->findByHash($hashId);
if ($contacts = $result->getContacts()) {
$stat = $result->getStat();
$this->updateStatDetails($stat, $comments, $dncReason);
$email = $stat->getEmail();
$channel = ($email) ? ['email' => $email->getId()] : 'email';
foreach ($contacts as $contact) {
$this->dncModel->addDncForContact($contact->getId(), $channel, $dncReason, $comments);
}
}
}
/**
* @param string $address
* @param string $comments
* @param int $dncReason
* @param int|null $channelId
*/
public function addFailureByAddress($address, $comments, $dncReason = DNC::BOUNCED, $channelId = null): void
{
$result = $this->finder->findByAddress($address);
if ($contacts = $result->getContacts()) {
foreach ($contacts as $contact) {
$channel = ($channelId) ? ['email' => $channelId] : 'email';
$this->dncModel->addDncForContact($contact->getId(), $channel, $dncReason, $comments);
}
}
}
/**
* @param int $dncReason
* @param int|null $channelId
*/
public function addFailureByContactId($id, $comments, $dncReason = DNC::BOUNCED, $channelId = null): void
{
$channel = ($channelId) ? ['email' => $channelId] : 'email';
$this->dncModel->addDncForContact($id, $channel, $dncReason, $comments);
}
private function updateStatDetails(Stat $stat, $comments, $dncReason): void
{
if (DNC::BOUNCED === $dncReason) {
$stat->setIsFailed(true);
}
$openDetails = $stat->getOpenDetails();
if (!isset($openDetails['bounces'])) {
$openDetails['bounces'] = [];
}
$dtHelper = new DateTimeHelper();
$openDetails['bounces'][] = [
'datetime' => $dtHelper->toUtcString(),
'reason' => $comments,
];
$stat->setOpenDetails($openDetails);
$this->emailStatModel->saveEntity($stat);
}
}