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,67 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Helper;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\EmailBundle\Entity\Stat;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class BotRatioHelper
{
/**
* @param string[] $blockedUserAgents
* @param string[] $blockedIPAddresses
*/
public function __construct(
#[Autowire(env: 'float:MAUTIC_BOT_HELPER_BOT_RATIO_THRESHOLD')]
private float $botRatioThreshold = 0.6,
#[Autowire(env: 'int:MAUTIC_BOT_HELPER_TIME_EMAIL_THRESHOLD')]
private int $timeFromEmailThreshold = 2,
#[Autowire(env: 'json:MAUTIC_BOT_HELPER_BLOCKED_USER_AGENTS')]
private array $blockedUserAgents = [],
#[Autowire(env: 'json:MAUTIC_BOT_HELPER_BLOCKED_IP_ADDRESSES')]
private array $blockedIPAddresses = [],
) {
}
public function isHitByBot(Stat $emailStat, \DateTimeInterface $emailHitDateTime, IpAddress $ipAddress, string $userAgent): bool
{
$totalPoints = (int) $this->isUnderTimeThreshold($emailStat, $emailHitDateTime) +
(int) $this->isIpInIgnoreList($ipAddress) +
(int) $this->isUserAgentInIgnoreList($userAgent);
return $totalPoints / 3 >= $this->botRatioThreshold;
}
private function isUnderTimeThreshold(Stat $emailStat, \DateTimeInterface $emailHitDateTime): bool
{
$timeFromSend = $emailHitDateTime->getTimestamp() - $emailStat->getDateSent()->getTimestamp();
return $timeFromSend < $this->timeFromEmailThreshold;
}
private function isIpInIgnoreList(IpAddress $ipAddress): bool
{
// Create a clone so that setting up do not track IP list here will not update original blocked Ip List
$ipAddressLocal = clone $ipAddress;
$ipAddressLocal->setDoNotTrackList($this->blockedIPAddresses);
return !$ipAddressLocal->isTrackable();
}
private function isUserAgentInIgnoreList(string $userAgent): bool
{
foreach ($this->blockedUserAgents as $blockedUserAgent) {
if (str_contains($userAgent, $blockedUserAgent)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Helper\DTO;
use Mautic\EmailBundle\Helper\Exception\TokenNotFoundOrEmptyException;
use Symfony\Component\Mime\Address;
final class AddressDTO
{
private ?string $name = null;
public function __construct(private string $email, ?string $name = null)
{
$this->setName($name);
}
/**
* @param array<string,?string> $address
*/
public static function fromAddressArray(array $address): self
{
$email = key($address);
if (!$email) {
throw new \InvalidArgumentException('Address array must have an email as key');
}
return new self($email, $address[$email] ?? null);
}
public function getEmail(): string
{
return $this->email;
}
public function getName(): ?string
{
return $this->name;
}
public function isEmpty(): bool
{
return empty($this->email);
}
/**
* @param array<string,mixed> $contact
*
* @throws TokenNotFoundOrEmptyException
*/
public function getEmailTokenValue(array $contact): string
{
if (!preg_match('/{contactfield=(.*?)}/', $this->email, $matches)) {
throw new TokenNotFoundOrEmptyException();
}
$emailToken = $matches[1];
if (empty($contact[$emailToken])) {
throw new TokenNotFoundOrEmptyException("$emailToken was not found or empty in the contact array");
}
return $contact[$emailToken];
}
/**
* @param array<string,mixed> $contact
*
* @throws TokenNotFoundOrEmptyException
*/
public function getNameTokenValue(array $contact): string
{
if (!preg_match('/{contactfield=(.*?)}/', $this->name, $matches)) {
throw new TokenNotFoundOrEmptyException();
}
$nameToken = $matches[1];
if (empty($contact[$nameToken])) {
throw new TokenNotFoundOrEmptyException("$nameToken was not found or empty in the contact array");
}
return $contact[$nameToken];
}
public function isEmailTokenized(): bool
{
return (bool) preg_match('/{contactfield=(.*?)}/', $this->email);
}
public function isNameTokenized(): bool
{
return (bool) ($this->name ? preg_match('/{contactfield=(.*?)}/', $this->name) : false);
}
/**
* @return array<string,?string>
*/
public function getAddressArray(): array
{
return [$this->email => $this->name];
}
public function toMailerAddress(): Address
{
return new Address($this->email, $this->name ?? '');
}
/**
* Decode apostrophes and other special characters.
*/
public function setName(?string $name): void
{
if (!$name) {
$this->name = null;
return;
}
$this->name = trim(html_entity_decode($name, ENT_QUOTES));
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Helper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
final class EmailConfig implements EmailConfigInterface
{
public function __construct(private CoreParametersHelper $coreParametersHelper)
{
}
public function isDraftEnabled(): bool
{
return (bool) $this->coreParametersHelper->get('email_draft_enabled', false);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Helper;
interface EmailConfigInterface
{
public function isDraftEnabled(): bool;
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Mautic\EmailBundle\Helper;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\EmailValidationEvent;
use Mautic\EmailBundle\Exception\InvalidEmailException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Contracts\Translation\TranslatorInterface;
class EmailValidator
{
public function __construct(
protected TranslatorInterface $translator,
protected EventDispatcherInterface $dispatcher,
) {
}
/**
* Validate that an email is the correct format, doesn't have invalid characters, a MX record is associated with the domain, and
* leverage integrations to validate.
*
* @param bool $doDnsCheck
*
* @throws UnexpectedValueException
* @throws InvalidEmailException
*/
public function validate($address, $doDnsCheck = false): void
{
if (!is_string($address)) {
throw new UnexpectedValueException($address, 'string');
}
if (!$this->isValidFormat($address)) {
throw new InvalidEmailException($address, $this->translator->trans('mautic.email.address.invalid_format', ['%email%' => $address ?: '?']));
}
if ($this->hasValidCharacters($address)) {
throw new InvalidEmailException($address, $this->translator->trans('mautic.email.address.invalid_characters', ['%email%' => $address]));
}
if ($doDnsCheck && !$this->hasValidDomain($address)) {
throw new InvalidEmailException($address, $this->translator->trans('mautic.email.address.invalid_domain', ['%email%' => $address]));
}
$this->doPluginValidation($address);
}
/**
* Validates that email is in an acceptable format.
*
* @returns bool
*/
public function isValidFormat($address): bool
{
return !empty($address) && filter_var($address, FILTER_VALIDATE_EMAIL);
}
/**
* Validates that email does not have invalid characters.
*
* @returns bool
*/
public function hasValidCharacters($address)
{
$invalidChar = strpbrk($address, '^&*%');
return $invalidChar ? substr($invalidChar, 0, 1) : $invalidChar;
}
/**
* Validates if the domain of an email.
*
* @returns bool
*/
public function hasValidDomain($address): bool
{
[$user, $domain] = explode('@', $address);
return checkdnsrr($domain, 'MX');
}
/**
* Validate using 3rd party integrations.
*
* @throws InvalidEmailException
*/
public function doPluginValidation($address): void
{
$event = $this->dispatcher->dispatch(
new EmailValidationEvent($address),
EmailEvents::ON_EMAIL_VALIDATION
);
if (!$event->isValid()) {
throw new InvalidEmailException($address, $event->getInvalidReason());
}
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Helper\Exception;
class OwnerNotFoundException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Helper\Exception;
class TokenNotFoundOrEmptyException extends \Exception
{
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Helper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Helper\DTO\AddressDTO;
use Mautic\EmailBundle\Helper\Exception\OwnerNotFoundException;
use Mautic\EmailBundle\Helper\Exception\TokenNotFoundOrEmptyException;
use Mautic\LeadBundle\Entity\LeadRepository;
class FromEmailHelper
{
/**
* @var array<int,mixed[]>
*/
private array $owners = [];
private ?AddressDTO $defaultFrom = null;
/**
* @var mixed[]|null
*/
private ?array $lastOwner = null;
public function __construct(private CoreParametersHelper $coreParametersHelper, private LeadRepository $leadRepository)
{
}
public function setDefaultFrom(AddressDTO $from): void
{
$this->defaultFrom = $from;
}
/**
* @param mixed[] $contact
*/
public function getFromAddressConsideringOwner(AddressDTO $address, ?array $contact = null, ?Email $email = null): AddressDTO
{
// Reset last owner
$this->lastOwner = null;
// Check for token
if ($address->isEmailTokenized() || $address->isNameTokenized()) {
return $this->getEmailFromToken($address, $contact, true, $email);
}
if (!$contact) {
return $address;
}
try {
return $this->getFromEmailAsOwner($contact, $email);
} catch (OwnerNotFoundException) {
return $this->getFrom($email);
}
}
/**
* @param mixed[] $contact
*/
public function getFromAddressDto(AddressDTO $address, ?array $contact = null, ?Email $email = null): AddressDTO
{
// Reset last owner
$this->lastOwner = null;
// Check for token
if ($address->isEmailTokenized() || $address->isNameTokenized()) {
return $this->getEmailFromToken($address, $contact, false, $email);
}
return $address;
}
/**
* @return mixed[]
*
* @throws OwnerNotFoundException
*/
public function getContactOwner(int $userId, ?Email $email = null): array
{
// Reset last owner
$this->lastOwner = null;
if ($email) {
if (!$email->getUseOwnerAsMailer()) {
throw new OwnerNotFoundException("mailer_is_owner is not enabled for this email ({$email->getId()})");
}
} elseif (!$this->coreParametersHelper->get('mailer_is_owner')) {
throw new OwnerNotFoundException('mailer_is_owner is not enabled in global configuration');
}
if (isset($this->owners[$userId])) {
return $this->lastOwner = $this->owners[$userId];
}
if ($owner = $this->leadRepository->getLeadOwner($userId)) {
$this->owners[$userId] = $this->lastOwner = $owner;
return $owner;
}
throw new OwnerNotFoundException();
}
public function getSignature(): string
{
if (!$this->lastOwner) {
return '';
}
return $this->replaceSignatureTokens($this->lastOwner);
}
/**
* @param mixed[] $owner
*/
private function replaceSignatureTokens(array $owner): string
{
$signature = nl2br($owner['signature'] ?? '');
$signature = str_replace('|FROM_NAME|', $owner['first_name'].' '.$owner['last_name'], $signature);
foreach ($owner as $key => $value) {
$token = sprintf('|USER_%s|', strtoupper($key));
$signature = str_replace($token, (string) $value, (string) $signature);
}
return $signature;
}
public function getFrom(?Email $email): AddressDTO
{
if ($email && $email->getFromAddress()) {
return new AddressDTO($email->getFromAddress(), $email->getFromName());
}
return $this->getDefaultFrom();
}
private function getDefaultFrom(): AddressDTO
{
if ($this->defaultFrom) {
return $this->defaultFrom;
}
return $this->getSystemDefaultFrom();
}
private function getSystemDefaultFrom(): AddressDTO
{
$email = $this->coreParametersHelper->get('mailer_from_email');
$name = $this->coreParametersHelper->get('mailer_from_name') ?: null;
return new AddressDTO($email, $name);
}
/**
* @param mixed[] $contact
*/
private function getEmailFromToken(AddressDTO $address, ?array $contact = null, bool $asOwner = true, ?Email $email = null): AddressDTO
{
try {
if (!$contact) {
throw new TokenNotFoundOrEmptyException();
}
$name = $address->isNameTokenized() ? $address->getNameTokenValue($contact) : $address->getName();
} catch (TokenNotFoundOrEmptyException) {
$name = $this->defaultFrom ? $this->defaultFrom->getName() : $this->getSystemDefaultFrom()->getName();
}
try {
if (!$contact) {
throw new TokenNotFoundOrEmptyException();
}
$emailAddress = $address->isEmailTokenized() ? $address->getEmailTokenValue($contact) : $address->getEmail();
return new AddressDTO($emailAddress, $name);
} catch (TokenNotFoundOrEmptyException) {
if ($contact && $asOwner) {
try {
return $this->getFromEmailAsOwner($contact, $email);
} catch (OwnerNotFoundException) {
}
}
return $this->getDefaultFrom();
}
}
/**
* @param mixed[] $contact
*
* @throws OwnerNotFoundException
*/
private function getFromEmailAsOwner(array $contact, ?Email $email = null): AddressDTO
{
if (empty($contact['owner_id'])) {
throw new OwnerNotFoundException();
}
$owner = $this->getContactOwner((int) $contact['owner_id'], $email);
$ownerEmail = $owner['email'];
$ownerName = sprintf('%s %s', $owner['first_name'], $owner['last_name']);
// Decode apostrophes and other special characters
$ownerName = trim(html_entity_decode($ownerName, ENT_QUOTES));
return new AddressDTO($ownerEmail, $ownerName);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Mautic\EmailBundle\Helper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
final class MailHashHelper
{
public function __construct(private CoreParametersHelper $coreParametersHelper)
{
}
public function getEmailHash(string $email): string
{
$secret = $this->coreParametersHelper->get('secret_key');
return self::getEmailHashForSecret($email, $secret);
}
public static function getEmailHashForSecret(string $email, string $secret): string
{
return hash_hmac('sha256', $email, $secret);
}
}

View File

@@ -0,0 +1,550 @@
<?php
namespace Mautic\EmailBundle\Helper;
class PlainTextHelper
{
public const ENCODING = 'UTF-8';
/**
* Contains the HTML content to convert.
*/
protected string $html = '';
/**
* Contains the converted, formatted text.
*
* @var string
*/
protected $text;
/**
* Maximum width of the formatted text, in columns.
*
* Set this value to 0 (or less) to ignore word wrapping
* and not constrain text to a fixed-width column.
*
* @var int
*/
protected $width = 70;
/**
* List of preg* regular expression patterns to search for,
* used in conjunction with $replace.
*
* @var array
*
* @see $replace
*/
protected $search = [
"/\r/", // Non-legal carriage return
"/[\n\t]+/", // Newlines and tabs
'/<head[^>]*>.*?<\/head>/i', // <head>
'/<script[^>]*>.*?<\/script>/i', // <script>s -- which strip_tags supposedly has problems with
'/<style[^>]*>.*?<\/style>/i', // <style>s -- which strip_tags supposedly has problems with
'/<p[^>]*>/i', // <P>
'/<br[^>]*>/i', // <br>
'/<i[^>]*>(.*?)<\/i>/i', // <i>
'/<em[^>]*>(.*?)<\/em>/i', // <em>
'/(<ul[^>]*>|<\/ul>)/i', // <ul> and </ul>
'/(<ol[^>]*>|<\/ol>)/i', // <ol> and </ol>
'/(<dl[^>]*>|<\/dl>)/i', // <dl> and </dl>
'/<li[^>]*>(.*?)<\/li>/i', // <li> and </li>
'/<dd[^>]*>(.*?)<\/dd>/i', // <dd> and </dd>
'/<dt[^>]*>(.*?)<\/dt>/i', // <dt> and </dt>
'/<li[^>]*>/i', // <li>
'/<hr[^>]*>/i', // <hr>
'/<div[^>]*>/i', // <div>
'/(<table[^>]*>|<\/table>)/i', // <table> and </table>
'/(<tr[^>]*>|<\/tr>)/i', // <tr> and </tr>
'/<td[^>]*>(.*?)<\/td>/i', // <td> and </td>
'/<span class="_html2text_ignore">.+?<\/span>/i', // <span class="_html2text_ignore">...</span>
];
/**
* List of pattern replacements corresponding to patterns searched.
*
* @var array
*
* @see $search
*/
protected $replace = [
'', // Non-legal carriage return
' ', // Newlines and tabs
'', // <head>
'', // <script>s -- which strip_tags supposedly has problems with
'', // <style>s -- which strip_tags supposedly has problems with
"\n\n", // <P>
"\n", // <br>
'_\\1_', // <i>
'_\\1_', // <em>
"\n\n", // <ul> and </ul>
"\n\n", // <ol> and </ol>
"\n\n", // <dl> and </dl>
"\t* \\1\n", // <li> and </li>
" \\1\n", // <dd> and </dd>
"\t* \\1", // <dt> and </dt>
"\n\t* ", // <li>
"\n-------------------------\n", // <hr>
"<div>\n", // <div>
"\n\n", // <table> and </table>
"\n", // <tr> and </tr>
"\t\t\\1\n", // <td> and </td>
'', // <span class="_html2text_ignore">...</span>
];
/**
* List of preg* regular expression patterns to search for,
* used in conjunction with $entReplace.
*
* @var array
*
* @see $entReplace
*/
protected $entSearch = [
'/&#153;/i', // TM symbol in win-1252
'/&#151;/i', // m-dash in win-1252
'/&(amp|#38);/i', // Ampersand: see converter()
'/[ ]{2,}/', // Runs of spaces, post-handling
];
/**
* List of pattern replacements corresponding to patterns searched.
*
* @var array
*
* @see $entSearch
*/
protected $entReplace = [
'™', // TM symbol
'—', // m-dash
'|+|amp|+|', // Ampersand: see converter()
' ', // Runs of spaces, post-handling
];
/**
* List of preg* regular expression patterns to search for
* and replace using callback function.
*
* @var array
*/
protected $callbackSearch = [
'/<(h)[123456]( [^>]*)?>(.*?)<\/h[123456]>/i', // h1 - h6
'/<(b)( [^>]*)?>(.*?)<\/b>/i', // <b>
'/<(strong)( [^>]*)?>(.*?)<\/strong>/i', // <strong>
'/<(th)( [^>]*)?>(.*?)<\/th>/i', // <th> and </th>
'/<(a) [^>]*href=("|\')([^"\']+)\2([^>]*)>(.*?)<\/a>/is', // <a href="">
];
/**
* List of preg* regular expression patterns to search for in PRE body,
* used in conjunction with $preReplace.
*
* @var array
*
* @see $preReplace
*/
protected $preSearch = [
"/\n/",
"/\t/",
'/ /',
'/<pre[^>]*>/',
'/<\/pre>/',
];
/**
* List of pattern replacements corresponding to patterns searched for PRE body.
*
* @var array
*
* @see $preSearch
*/
protected $preReplace = [
'<br>',
'&nbsp;&nbsp;&nbsp;&nbsp;',
'&nbsp;',
'',
'',
];
/**
* Temporary workspace used during PRE processing.
*
* @var string
*/
protected $preContent = '';
/**
* Indicates whether content in the $html variable has been converted yet.
*
* @var bool
*
* @see $html, $text
*/
protected $converted = false;
/**
* Contains URL addresses from links to be rendered in plain text.
*
* @var array
*
* @see buildlinkList()
*/
protected $linkList = [];
/**
* Various configuration options (able to be set in the constructor).
*
* @var array<string, mixed>
*/
protected array $options = [
'do_links' => 'inline', // 'none'
// 'inline' (show links inline)
// 'nextline' (show links on the next line)
// 'table' (if a table of link URLs should be listed after the text.
'width' => 70, // Maximum width of the formatted text, in columns.
// Set this value to 0 (or less) to ignore word wrapping
// and not constrain text to a fixed-width column.
'base_url' => '',
'preview_length' => 119, // Maximum length of the preview text
];
/**
* @param array<string, mixed> $options Set configuration options
*/
public function __construct(array $options = [])
{
$this->options = array_merge($this->options, $options);
}
/**
* Set the source HTML.
*
* @param string|null $html HTML source content
*
* @return PlainTextHelper
*/
public function setHtml($html)
{
$this->html = $html ?? '';
$this->converted = false;
return $this;
}
/**
* Returns the text, converted from HTML.
*/
public function getText(): string
{
if (!$this->converted) {
$this->convert();
}
return trim($this->text);
}
public function getPreview(): string
{
$textContent = $this->getText();
$preview = trim(substr($textContent, 0, $this->options['preview_length']));
// If the text is longer than the preview length, append an ellipsis
if (strlen($textContent) > $this->options['preview_length']) {
$preview .= '...';
}
return $preview;
}
protected function convert()
{
$this->linkList = [];
$text = trim(stripslashes($this->html));
$this->converter($text);
if ($this->linkList) {
$text .= "\n\nLinks:\n------\n";
foreach ($this->linkList as $i => $url) {
$text .= '['.($i + 1).'] '.$url."\n";
}
}
$this->text = $text;
$this->converted = true;
}
protected function converter(&$text)
{
$this->convertBlockquotes($text);
$this->convertPre($text);
$text = preg_replace($this->search, $this->replace, $text);
$text = preg_replace_callback($this->callbackSearch, [$this, 'pregCallback'], $text);
$text = strip_tags($text);
$text = preg_replace($this->entSearch, $this->entReplace, $text);
$text = html_entity_decode($text, ENT_QUOTES, self::ENCODING);
// Remove unknown/unhandled entities (this cannot be done in search-and-replace block)
$text = preg_replace('/&([a-zA-Z0-9]{2,6}|#[0-9]{2,4});/', '', $text);
// Convert "|+|amp|+|" into "&", need to be done after handling of unknown entities
// This properly handles situation of "&amp;quot;" in input string
$text = str_replace('|+|amp|+|', '&', $text);
// Normalise empty lines
$text = preg_replace("/\n\s+\n/", "\n\n", $text);
$text = preg_replace("/[\n]{3,}/", "\n\n", $text);
// remove leading empty lines (can be produced by eg. P tag on the beginning)
$text = ltrim($text, "\n");
if ($this->options['width'] > 0) {
$text = $this->linewrap($text, $this->options['width']);
}
}
/**
* Helper function called by preg_replace() on link replacement.
*
* Maintains an internal list of links to be displayed at the end of the
* text, with numeric indices to the original point in the text they
* appeared. Also makes an effort at identifying and handling absolute
* and relative links.
*
* @param string $link URL of the link
* @param string $display Part of the text to associate number with
*
* @return string
*/
protected function buildlinkList($link, $display, ?string $linkOverride = null)
{
$linkMethod = $linkOverride ?: $this->options['do_links'];
if ('none' == $linkMethod) {
return $display;
}
// Ignored link types
if (preg_match('!^(javascript:|mailto:|#)!i', $link)) {
return $display;
}
if (preg_match('!^([a-z][a-z0-9.+-]+:)!i', $link) || preg_match('!({|%7B)(.*?)(}|%7D)!', $link)) {
$url = $link;
} else {
$url = $this->options['base_url'];
if (!str_starts_with($link, '/')) {
$url .= '/';
}
$url .= $link;
}
if ('table' == $linkMethod) {
if (false === ($index = array_search($url, $this->linkList))) {
$index = count($this->linkList);
$this->linkList[] = $url;
}
return $display.' ['.($index + 1).']';
} elseif ('nextline' == $linkMethod) {
return $display."\n[".$url.']';
} else { // link_method defaults to inline
return $display.' ['.$url.']';
}
}
protected function convertPre(&$text)
{
// get the content of PRE element
while (preg_match('/<pre[^>]*>(.*)<\/pre>/ismU', $text, $matches)) {
$this->preContent = $matches[1];
// Run our defined tags search-and-replace with callback
$this->preContent = preg_replace_callback(
$this->callbackSearch,
[$this, 'pregCallback'],
$this->preContent
);
// convert the content
$this->preContent = sprintf(
'<div><br>%s<br></div>',
preg_replace($this->preSearch, $this->preReplace, $this->preContent)
);
// replace the content (use callback because content can contain $0 variable)
$text = preg_replace_callback(
'/<pre[^>]*>.*<\/pre>/ismU',
[$this, 'pregPreCallback'],
$text,
1
);
// free memory
$this->preContent = '';
}
}
/**
* Helper function for BLOCKQUOTE body conversion.
*
* @param string $text HTML content
*/
protected function convertBlockquotes(&$text)
{
if (preg_match_all('/<\/*blockquote[^>]*>/i', $text, $matches, PREG_OFFSET_CAPTURE)) {
$start = 0;
$taglen = 0;
$level = 0;
$diff = 0;
foreach ($matches[0] as $m) {
if ('<' == $m[0][0] && '/' == $m[0][1]) {
--$level;
if ($level < 0) {
$level = 0; // malformed HTML: go to next blockquote
} elseif ($level > 0) {
// skip inner blockquote
} else {
$end = $m[1];
$len = $end - $taglen - $start;
// Get blockquote content
$body = substr($text, $start + $taglen - $diff, $len);
// Set text width
$pWidth = $this->options['width'];
if ($this->options['width'] > 0) {
$this->options['width'] -= 2;
}
// Convert blockquote content
$body = trim($body);
$this->converter($body);
// Add citation markers and create PRE block
$body = preg_replace('/((^|\n)>*)/', '\\1> ', trim($body));
$body = '<pre>'.htmlspecialchars($body).'</pre>';
// Re-set text width
$this->options['width'] = $pWidth;
// Replace content
$text = substr($text, 0, $start - $diff)
.$body.substr($text, $end + strlen($m[0]) - $diff);
$diff = $len + $taglen + strlen($m[0]) - strlen($body);
unset($body);
}
} else {
if (0 == $level) {
$start = $m[1];
$taglen = strlen($m[0]);
}
++$level;
}
}
}
}
/**
* Callback function for preg_replace_callback use.
*
* @param array $matches PREG matches
*
* @return string
*/
protected function pregCallback($matches)
{
switch (strtolower($matches[1])) {
case 'b':
case 'strong':
return $matches[3];
case 'th':
return $this->toupper("\t\t".$matches[3]."\n");
case 'h':
return $this->toupper("\n\n".$matches[3]."\n\n");
case 'a':
// override the link method
$linkOverride = null;
if (preg_match('/_html2text_link_(\w+)/', $matches[4], $linkOverrideMatch)) {
$linkOverride = $linkOverrideMatch[1];
}
// Remove spaces in URL (#1487805)
$url = str_replace(' ', '', $matches[3]);
return $this->buildlinkList($url, $matches[5], $linkOverride);
}
return '';
}
/**
* Callback function for preg_replace_callback use in PRE content handler.
*
* @param array $matches PREG matches
*
* @return string
*/
protected function pregPreCallback(/* @noinspection PhpUnusedParameterInspection */ $matches)
{
return $this->preContent;
}
/**
* Strtoupper function with HTML tags and entities handling.
*
* @param string $str Text to convert
*
* @return string Converted text
*/
private function toupper($str): string
{
// string can contain HTML tags
$chunks = preg_split('/(<[^>]*>)/', $str, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
// convert toupper only the text between HTML tags
foreach ($chunks as $i => $chunk) {
if ('<' != $chunk[0]) {
$chunks[$i] = $this->strtoupper($chunk);
}
}
return implode('', $chunks);
}
/**
* Strtoupper multibyte wrapper function with HTML entities handling.
*
* @param string $str Text to convert
*
* @return string Converted text
*/
private function strtoupper($str): string
{
$str = html_entity_decode($str, ENT_COMPAT, self::ENCODING);
if (function_exists('mb_strtoupper')) {
$str = mb_strtoupper($str, self::ENCODING);
} else {
$str = strtoupper($str);
}
return htmlspecialchars($str, ENT_COMPAT, self::ENCODING);
}
/**
* @param string $breakline
* @param bool|false $cut
*/
private function linewrap($text, $width, $breakline = "\n", $cut = false): string
{
$lines = explode("\n", $text);
$text = '';
foreach ($lines as $line) {
$text .= trim(wordwrap(trim($line), $width, $breakline, $cut));
$text .= "\n";
}
return $text;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Mautic\EmailBundle\Helper;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\LeadBundle\Entity\Lead;
class PointEventHelper
{
public function __construct(private EmailModel $emailModel)
{
}
public static function validateEmail($eventDetails, $action): bool
{
if (null === $eventDetails) {
return false;
}
$emailId = $eventDetails->getId();
if (isset($action['properties']['emails'])) {
$limitToEmails = $action['properties']['emails'];
}
if (!empty($limitToEmails) && !in_array($emailId, $limitToEmails)) {
// no points change
return false;
}
return true;
}
public function sendEmail($event, Lead $lead): bool
{
$properties = $event['properties'];
$emailId = (int) $properties['email'];
$email = $this->emailModel->getEntity($emailId);
// make sure the email still exists and is published
if (null != $email && $email->isPublished()) {
$leadFields = $lead->getFields();
if (isset($leadFields['core']['email']['value']) && $leadFields['core']['email']['value']) {
$leadCredentials = $lead->getProfileFields();
$leadCredentials['id'] = $lead->getId();
$options = ['source' => ['trigger', $event['id']]];
$emailSent = $this->emailModel->sendEmail($email, $leadCredentials, $options);
return is_array($emailSent) ? false : true;
}
}
return false;
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Mautic\EmailBundle\Helper;
use Mautic\EmailBundle\Stats\FetchOptions\EmailStatOptions;
use Mautic\EmailBundle\Stats\Helper\BouncedHelper;
use Mautic\EmailBundle\Stats\Helper\ClickedHelper;
use Mautic\EmailBundle\Stats\Helper\FailedHelper;
use Mautic\EmailBundle\Stats\Helper\OpenedHelper;
use Mautic\EmailBundle\Stats\Helper\SentHelper;
use Mautic\EmailBundle\Stats\Helper\UnsubscribedHelper;
use Mautic\EmailBundle\Stats\StatHelperContainer;
use Mautic\StatsBundle\Aggregate\Collection\StatCollection;
class StatsCollectionHelper
{
public const GENERAL_STAT_PREFIX = 'email';
public function __construct(
private StatHelperContainer $helperContainer,
) {
}
/**
* Fetch stats from listeners.
*
* @return mixed
*
* @throws \Mautic\EmailBundle\Stats\Exception\InvalidStatHelperException
*/
public function fetchSentStats(\DateTime $fromDateTime, \DateTime $toDateTime, EmailStatOptions $options)
{
return $this->helperContainer->getHelper(SentHelper::NAME)->fetchStats($fromDateTime, $toDateTime, $options);
}
/**
* Fetch stats from listeners.
*
* @return mixed
*
* @throws \Mautic\EmailBundle\Stats\Exception\InvalidStatHelperException
*/
public function fetchOpenedStats(\DateTime $fromDateTime, \DateTime $toDateTime, EmailStatOptions $options)
{
return $this->helperContainer->getHelper(OpenedHelper::NAME)->fetchStats($fromDateTime, $toDateTime, $options);
}
/**
* Fetch stats from listeners.
*
* @return mixed
*
* @throws \Mautic\EmailBundle\Stats\Exception\InvalidStatHelperException
*/
public function fetchFailedStats(\DateTime $fromDateTime, \DateTime $toDateTime, EmailStatOptions $options)
{
return $this->helperContainer->getHelper(FailedHelper::NAME)->fetchStats($fromDateTime, $toDateTime, $options);
}
/**
* Fetch stats from listeners.
*
* @return mixed
*
* @throws \Mautic\EmailBundle\Stats\Exception\InvalidStatHelperException
*/
public function fetchClickedStats(\DateTime $fromDateTime, \DateTime $toDateTime, EmailStatOptions $options)
{
return $this->helperContainer->getHelper(ClickedHelper::NAME)->fetchStats($fromDateTime, $toDateTime, $options);
}
/**
* Fetch stats from listeners.
*
* @return mixed
*
* @throws \Mautic\EmailBundle\Stats\Exception\InvalidStatHelperException
*/
public function fetchBouncedStats(\DateTime $fromDateTime, \DateTime $toDateTime, EmailStatOptions $options)
{
return $this->helperContainer->getHelper(BouncedHelper::NAME)->fetchStats($fromDateTime, $toDateTime, $options);
}
/**
* Fetch stats from listeners.
*
* @return mixed
*
* @throws \Mautic\EmailBundle\Stats\Exception\InvalidStatHelperException
*/
public function fetchUnsubscribedStats(\DateTime $fromDateTime, \DateTime $toDateTime, EmailStatOptions $options)
{
return $this->helperContainer->getHelper(UnsubscribedHelper::NAME)->fetchStats($fromDateTime, $toDateTime, $options);
}
/**
* Generate stats from Mautic's raw data.
*
* @throws \Mautic\EmailBundle\Stats\Exception\InvalidStatHelperException
*/
public function generateStats(
$statName,
\DateTime $fromDateTime,
\DateTime $toDateTime,
EmailStatOptions $options,
StatCollection $statCollection,
): void {
$this->helperContainer->getHelper($statName)->generateStats($fromDateTime, $toDateTime, $options, $statCollection);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Mautic\EmailBundle\Helper;
class UrlMatcher
{
public static function hasMatch(array $urlsToCheckAgainst, $urlToFind): bool
{
$urlToFind = self::sanitizeUrl($urlToFind);
foreach ($urlsToCheckAgainst as $url) {
$url = self::sanitizeUrl($url);
if (preg_match('/'.preg_quote($url, '/').'/i', $urlToFind)) {
return true;
}
}
return false;
}
/**
* @return mixed|string
*/
private static function sanitizeUrl($url)
{
// Handle escaped forward slashes as BC
$url = str_replace('\\/', '/', $url);
// Ignore ending slash
$url = rtrim($url, '/');
// Ignore http/https
$url = str_replace(['http://', 'https://'], '', $url);
// Remove preceding //
if (str_starts_with($url, '//')) {
$url = str_replace('//', '', $url);
}
return $url;
}
}