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,64 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Accessor;
class ConfigAccessor
{
/**
* @param mixed[] $config
*/
public function __construct(
private array $config,
) {
}
/**
* @return string
*/
public function getPath()
{
return $this->getProperty('imap_path');
}
/**
* @return string
*/
public function getUser()
{
return $this->getProperty('user');
}
/**
* @return string
*/
public function getHost()
{
return $this->getProperty('host');
}
/**
* @return string
*/
public function getFolder()
{
return $this->getProperty('folder');
}
public function getKey(): string
{
return $this->getPath().'_'.$this->getUser();
}
public function isConfigured(): bool
{
return $this->getHost() && $this->getFolder();
}
/**
* @return string|null
*/
protected function getProperty($property)
{
return $this->config[$property] ?? null;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail;
class Attachment
{
public $id;
public $name;
public $filePath;
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
class BounceNotFound extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
class CategoryNotFound extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
class FeedbackLoopNotFound extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
class NotConfiguredException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
class ReplyNotFound extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
class UnsubscriptionNotFound extends \Exception
{
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\ParseEmailEvent;
use Mautic\EmailBundle\MonitoredEmail\Accessor\ConfigAccessor;
use Mautic\EmailBundle\MonitoredEmail\Organizer\MailboxOrganizer;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class Fetcher
{
private ?array $mailboxes = null;
private array $log = [];
private int $processedMessageCounter = 0;
public function __construct(
private Mailbox $imapHelper,
private EventDispatcherInterface $dispatcher,
private TranslatorInterface $translator,
) {
}
/**
* @return $this
*/
public function setMailboxes(array $mailboxes)
{
$this->mailboxes = $mailboxes;
return $this;
}
/**
* @param int $limit
*/
public function fetch($limit = null): void
{
/** @var ParseEmailEvent $event */
$event = $this->dispatcher->dispatch(new ParseEmailEvent(), EmailEvents::EMAIL_PRE_FETCH);
// Get a list of criteria and group by it
$organizer = new MailboxOrganizer($event, $this->getConfigs());
$organizer->organize();
if (!$containers = $organizer->getContainers()) {
$this->log[] = $this->translator->trans('mautic.email.fetch.no_mailboxes_configured');
return;
}
foreach ($containers as $container) {
$path = $container->getPath();
$markAsSeen = $container->shouldMarkAsSeen();
foreach ($container->getCriteria() as $criteria => $mailboxes) {
try {
// Get mail and parse into Message objects
$this->imapHelper->switchMailbox($mailboxes[0]);
$mailIds = $this->imapHelper->searchMailBox($criteria);
$messages = $this->getMessages($mailIds, $limit, $markAsSeen);
$processed = count($messages);
if ($messages) {
$event->setMessages($messages)
->setKeys($mailboxes);
$this->dispatcher->dispatch($event, EmailEvents::EMAIL_PARSE);
}
$this->log[] = $this->translator->trans(
'mautic.email.fetch.processed',
['%count%' => $processed, '%imapPath%' => $path, '%criteria%' => $criteria]
);
if ($limit && $this->processedMessageCounter >= $limit) {
break;
}
} catch (\Exception $e) {
$this->log[] = $e->getMessage();
}
}
}
}
/**
* @return array
*/
public function getLog()
{
return $this->log;
}
/**
* @param int $limit
* @param bool $markAsSeen
*/
private function getMessages(array $mailIds, $limit, $markAsSeen): array
{
if (!count($mailIds)) {
return [];
}
$messages = [];
foreach ($mailIds as $id) {
$messages[] = $this->imapHelper->getMail($id, $markAsSeen);
++$this->processedMessageCounter;
if ($limit && $this->processedMessageCounter >= $limit) {
break;
}
}
return $messages;
}
private function getConfigs(): array
{
$mailboxes = [];
foreach ($this->mailboxes as $mailbox) {
$mailboxes[$mailbox] = new ConfigAccessor($this->imapHelper->getMailboxSettings($mailbox));
}
return $mailboxes;
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail;
class Message
{
public $id;
public $date;
public $subject;
public $fromName;
public $fromAddress;
public $to = [];
public $toString;
public $cc = [];
public $replyTo = [];
public $inReplyTo = false;
public $returnPath = false;
public $references = [];
public string $textPlain = '';
public $textHtml;
public string $dsnReport = '';
public string $dsnMessage = '';
public $fblReport;
public $fblMessage;
public $xHeaders = [];
/**
* @var Attachment[]
*/
protected $attachments = [];
public function addAttachment(Attachment $attachment): void
{
$this->attachments[$attachment->id] = $attachment;
}
/**
* @return Attachment[]
*/
public function getAttachments()
{
return $this->attachments;
}
/**
* Get array of internal HTML links placeholders.
*
* @return array attachmentId => link placeholder
*/
public function getInternalLinksPlaceholders(): array
{
return preg_match_all('/=["\'](ci?d:([\w\.%*@-]+))["\']/i', $this->textHtml, $matches) ? array_combine($matches[2], $matches[1]) : [];
}
/**
* @return mixed
*/
public function replaceInternalLinks($baseUri)
{
$baseUri = rtrim($baseUri, '\\/').'/';
$fetchedHtml = $this->textHtml;
foreach ($this->getInternalLinksPlaceholders() as $attachmentId => $placeholder) {
if (isset($this->attachments[$attachmentId])) {
$fetchedHtml = str_replace($placeholder, $baseUri.basename($this->attachments[$attachmentId]->filePath), $fetchedHtml);
}
}
return $fetchedHtml;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Organizer;
use Mautic\EmailBundle\MonitoredEmail\Accessor\ConfigAccessor;
class MailboxContainer
{
/**
* @var array
*/
protected $criteria = [];
/**
* @var bool
*/
protected $markAsSeen = true;
/**
* @var array
*/
protected $messages = [];
public function __construct(
protected ConfigAccessor $config,
) {
}
public function addCriteria($criteria, $mailbox): void
{
if (!isset($this->criteria[$criteria])) {
$this->criteria[$criteria] = [];
}
$this->criteria[$criteria][] = $mailbox;
}
/**
* Keep the messages in this mailbox as unseen.
*/
public function keepAsUnseen(): void
{
$this->markAsSeen = false;
}
/**
* @return bool
*/
public function shouldMarkAsSeen()
{
return $this->markAsSeen;
}
/**
* @return string
*/
public function getPath()
{
return $this->config->getPath();
}
/**
* @return array
*/
public function getCriteria()
{
return $this->criteria;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Organizer;
use Mautic\EmailBundle\Event\ParseEmailEvent;
use Mautic\EmailBundle\MonitoredEmail\Accessor\ConfigAccessor;
use Mautic\EmailBundle\MonitoredEmail\Mailbox;
class MailboxOrganizer
{
/**
* @var MailboxContainer[]
*/
protected $containers = [];
public function __construct(
protected ParseEmailEvent $event,
protected array $mailboxes,
) {
}
/**
* Organize the mailboxes into containers by IMAP connection and criteria.
*/
public function organize(): void
{
$criteriaRequested = $this->event->getCriteriaRequests();
$markAsSeenInstructions = $this->event->getMarkAsSeenInstructions();
/**
* @var string $name
* @var ConfigAccessor $config
*/
foreach ($this->mailboxes as $name => $config) {
// Switch mailbox to get information
if (!$config->isConfigured()) {
// Not configured so continue
continue;
}
$criteria = $criteriaRequested[$name] ?? Mailbox::CRITERIA_UNSEEN;
$markAsSeen = $markAsSeenInstructions[$name] ?? true;
$container = $this->getContainer($config);
if (!$markAsSeen) {
// Keep all the messages fetched from this folder as unseen
$container->keepAsUnseen();
}
$container->addCriteria($criteria, $name);
}
}
/**
* @return MailboxContainer[]
*/
public function getContainers()
{
return $this->containers;
}
/**
* @return MailboxContainer
*/
protected function getContainer(ConfigAccessor $config)
{
$key = $config->getKey();
if (!isset($this->containers[$key])) {
$this->containers[$key] = new MailboxContainer($config);
}
return $this->containers[$key];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor;
class Address
{
/**
* @param string $addresses String of email address from an email header
*/
public static function parseList($addresses): array
{
$results = [];
$parsedAddresses = imap_rfc822_parse_adrlist($addresses, 'default.domain.name');
foreach ($parsedAddresses as $parsedAddress) {
if (
isset($parsedAddress->host)
&& '.SYNTAX-ERROR.' != $parsedAddress->host
&& 'default.domain.name' != $parsedAddress->host
) {
$email = $parsedAddress->mailbox.'@'.$parsedAddress->host;
$name = $parsedAddress->personal ?? null;
$results[$email] = $name;
}
}
return $results;
}
public static function parseAddressForStatHash($address): ?string
{
if (preg_match('#^(.*?)\+(.*?)@(.*?)$#', $address, $parts)) {
if (strstr($parts[2], '_')) {
// Has an ID hash so use it to find the lead
[$ignore, $hashId] = explode('_', $parts[2]);
return $hashId;
}
}
return null;
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Mailer\Transport\BounceProcessorInterface;
use Mautic\EmailBundle\Model\EmailStatModel;
use Mautic\EmailBundle\MonitoredEmail\Exception\BounceNotFound;
use Mautic\EmailBundle\MonitoredEmail\Message;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\BouncedEmail;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Parser;
use Mautic\EmailBundle\MonitoredEmail\Search\ContactFinder;
use Mautic\LeadBundle\Model\DoNotContact;
use Mautic\LeadBundle\Model\LeadModel;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class Bounce implements ProcessorInterface
{
private const RETRY_COUNT = 5;
/**
* @var string
*/
protected $bouncerAddress;
/**
* @var Message
*/
protected $message;
public function __construct(
protected TransportInterface $transport,
protected ContactFinder $contactFinder,
protected EmailStatModel $emailStatModel,
protected LeadModel $leadModel,
protected TranslatorInterface $translator,
protected LoggerInterface $logger,
protected DoNotContact $doNotContact,
) {
}
public function process(Message $message): bool
{
$this->message = $message;
$bounce = false;
$this->logger->debug('MONITORED EMAIL: Processing message ID '.$this->message->id.' for a bounce');
// Does the transport have special handling such as Amazon SNS?
if ($this->transport instanceof BounceProcessorInterface) {
try {
$bounce = $this->transport->processBounce($this->message);
} catch (BounceNotFound) {
// Attempt to parse a bounce the standard way
}
}
if (!$bounce) {
try {
$bounce = (new Parser($this->message))->parse();
} catch (BounceNotFound) {
return false;
}
}
$searchResult = $this->contactFinder->find($bounce->getContactEmail(), $bounce->getBounceAddress());
if (!$contacts = $searchResult->getContacts()) {
// No contacts found so bail
return false;
}
$stat = $searchResult->getStat();
$channel = 'email';
if ($stat) {
// Update stat entry
$this->updateStat($stat, $bounce);
if ($stat->getEmail() instanceof Email) {
// We know the email ID so set it to append to the the DNC record
$channel = ['email' => $stat->getEmail()->getId()];
}
}
$comments = $this->translator->trans('mautic.email.bounce.reason.'.$bounce->getRuleCategory());
foreach ($contacts as $contact) {
$this->doNotContact->addDncForContact($contact->getId(), $channel, \Mautic\LeadBundle\Entity\DoNotContact::BOUNCED, $comments);
}
return true;
}
protected function updateStat(Stat $stat, BouncedEmail $bouncedEmail)
{
$dtHelper = new DateTimeHelper();
$openDetails = $stat->getOpenDetails();
if (!isset($openDetails['bounces'])) {
$openDetails['bounces'] = [];
}
$openDetails['bounces'][] = [
'datetime' => $dtHelper->toUtcString(),
'reason' => $bouncedEmail->getRuleCategory(),
'code' => $bouncedEmail->getRuleNumber(),
'type' => $bouncedEmail->getType(),
];
$stat->setOpenDetails($openDetails);
$retryCount = $stat->getRetryCount();
++$retryCount;
$stat->setRetryCount($retryCount);
if ($bouncedEmail->isFinal() || $retryCount >= self::RETRY_COUNT) {
$stat->setIsFailed(true);
}
$this->emailStatModel->saveEntity($stat);
}
}

View File

@@ -0,0 +1,523 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Bounce;
use Mautic\EmailBundle\MonitoredEmail\Exception\BounceNotFound;
use Mautic\EmailBundle\MonitoredEmail\Message;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition\Category;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition\Type;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Mapper\CategoryMapper;
class BodyParser
{
/**
* @throws BounceNotFound
*/
public function getBounce(Message $message, $contactEmail = null): BouncedEmail
{
$report = $this->parse($message->textPlain, $contactEmail);
if (!$report['email']) {
throw new BounceNotFound();
}
$bounce = new BouncedEmail();
$bounce->setContactEmail($report['email'])
->setType($report['bounce_type'])
->setRuleCategory($report['rule_cat'])
->setRuleNumber($report['rule_no'])
->setIsFinal($report['remove']);
return $bounce;
}
/**
* @todo - refactor to get rid of the if/else statements
*
* @param string $knownEmail
*/
public function parse($body, $knownEmail = ''): array
{
// initialize the result array
$result = [
'email' => $knownEmail,
'bounce_type' => false,
'remove' => 0,
'rule_cat' => Category::UNRECOGNIZED,
'rule_no' => '0000',
];
// ======== rule =========
/*
* Email is already known likely for a x-failed-recipients header; most likely Gmail bounce
*/
if ('' !== $knownEmail) {
/*
* rule: mailbox unknown;
* sample:
* The error that the other server returned was:
* 550-5.1.1 The email account that you tried to reach does not exist.
*/
if (preg_match('/email.*?does not exist/i', $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0237';
}
/*
* rule: mailbox unknown;
* sample:
* The error that the other server returned was:
* 553-5.1.2 We weren't able to find the recipient domain.
*/
elseif (preg_match('/find the recipient domain/i', $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0237';
}
/*
* rule: mailbox unknown;
* sample:
* The error that the other server returned was:
* 550 5.1.1 RESOLVER.ADR.RecipNotFound; not found
*/
elseif (preg_match('/RecipNotFound/i', $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0237';
}
/*
* rule: user reject;
* sample:
* The error that the other server returned was:
* 554 5.7.1 Your mail could not be delivered because the recipient is only accepting mail from specific email addresses.
*/
elseif (preg_match('/accepting mail from specific email addresses/i', $body, $match)) {
$result['rule_cat'] = Category::USER_REJECT;
$result['rule_no'] = '0156';
}
/*
* rule: mailbox inactive;
* sample:
* The error that the other server returned was:
* 550-5.2.1 The email account that you tried to reach is disabled.
*/
elseif (preg_match('/email.*?disabled/i', $body, $match)) {
$result['rule_cat'] = Category::INACTIVE;
$result['rule_no'] = '0171';
}
/*
* rule: mailbox warning;
* sample:
* The error that the other server returned was:
* 550-5.2.1 The user you are trying to contact is receiving mail at a rate that prevents additional messages from being delivered.
*/
elseif (preg_match('/user.*?rate that prevents/i', $body, $match)) {
$result['rule_cat'] = Category::WARNING;
$result['rule_no'] = '0000';
}
/*
* rule: mailbox full;
* sample:
* The error that the other server returned was:
* 550-5.7.1 Email quota exceeded.
*/
elseif (preg_match('/email quota exceeded/i', $body, $match)) {
$result['rule_cat'] = Category::FULL;
$result['rule_no'] = '0219';
}
/*
* rule: mailbox full;
* sample:
* The error that the other server returned was:
* 552-5.2.2 The email account that you tried to reach is over quota.
*/
if (preg_match('/email.*?over quota/i', $body, $match)) {
$result['rule_cat'] = Category::FULL;
$result['rule_no'] = '0219';
}
/*
* rule: mailbox antispam;
* sample:
* The error that the other server returned was:
* 550-5.7.1 Our system has detected an unusual rate of unsolicited mail originating from your IP address. To protect our users from spam,
* mail sent from your IP address has been blocked.
*/
elseif (preg_match('/unsolicited mail/i', $body, $match)) {
$result['rule_cat'] = Category::ANTISPAM;
$result['rule_no'] = '0230';
}
/*
* rule: mailbox antispam;
* sample:
* The error that the other server returned was:
* 550-5.7.1 The user or domain that you are sending to (or from) has a policy that prohibited the mail that you sent.
*/
elseif (preg_match('/policy that prohibited/i', $body, $match)) {
$result['rule_cat'] = Category::ANTISPAM;
$result['rule_no'] = '0230';
}
/*
* rule: mailbox oversize;
* sample:
* The error that the other server returned was:
* 552-5.2.3 Your message exceeded Google's message size limits.
*/
elseif (preg_match('/message size limits/i', $body, $match)) {
$result['rule_cat'] = Category::OVERSIZE;
$result['rule_no'] = '0146';
}
}
/*
* rule: mailbox unknown;
* sample:
* xxxxx@yourdomain.com
* no such address here
*/
if (preg_match("/(\S+@\S+\w).*\n?.*no such address here/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0237';
$result['email'] = $match[1];
}
/*
* <xxxxx@yourdomain.com>:
* 111.111.111.111 does not like recipient.
* Remote host said: 550 User unknown
*/
elseif (preg_match("/<(\S+@\S+\w)>.*\n?.*\n?.*user unknown/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0236';
$result['email'] = $match[1];
}
/*
* rule: mailbox unknown;
* sample:
* <xxxxx@yourdomain.com>:
* Sorry, no mailbox here by that name. vpopmail (#5.1.1)
*/
elseif (preg_match("/<(\S+@\S+\w)>.*\n?.*no mailbox/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0157';
$result['email'] = $match[1];
}
/*
* rule: mailbox unknown;
* sample:
* xxxxx@yourdomain.com<br>
* local: Sorry, can't find user's mailbox. (#5.1.1)<br>
*/
elseif (preg_match("/(\S+@\S+\w)<br>.*\n?.*\n?.*can't find.*mailbox/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0164';
$result['email'] = $match[1];
}
/*
* rule: mailbox unknown;
* sample:
* ##########################################################
* # This is an automated response from a mail delivery #
* # program. Your message could not be delivered to #
* # the following address: #
* # #
* # "|/usr/local/bin/mailfilt -u #dkms" #
* # (reason: Can't create output) #
* # (expanded from: <xxxxx@yourdomain.com>) #
* # #
*/
elseif (preg_match("/Can't create output.*\n?.*<(\S+@\S+\w)>/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0169';
$result['email'] = $match[1];
}
/*
* rule: mailbox unknown;
* sample:
* ????????????????:
* xxxxx@yourdomain.com : ????, ?????.
*/
elseif (preg_match("/(\S+@\S+\w).*=D5=CA=BA=C5=B2=BB=B4=E6=D4=DA/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0174';
$result['email'] = $match[1];
}
/*
* rule: mailbox unknown;
* sample:
* xxxxx@yourdomain.com
* Unrouteable address
*/
elseif (preg_match("/(\S+@\S+\w).*\n?.*Unrouteable address/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0179';
$result['email'] = $match[1];
}
/*
* rule: mailbox unknown;
* sample:
* Delivery to the following recipients failed.
* xxxxx@yourdomain.com
*/
elseif (preg_match("/delivery[^\n\r]+failed[ \S]*\s+(\S+@\S+\w)\s/is", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0013';
$result['email'] = $match[1];
}
/*
* rule: mailbox error (Amazon SES);
* sample:
* An error occurred while trying to deliver the mail to the following recipients:
* xxxxx@yourdomain.com
*/
elseif (preg_match("/an\s+error\s+occurred\s+while\s+trying\s+to\s+deliver\s+the\s+mail\s+to\s+the\s+following\s+recipients:\r\n\s*(\S+@\S+\w)/is", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0013';
$result['bounce_type'] = Type::HARD;
$result['remove'] = 1;
$result['email'] = $match[1];
$result['email'] = preg_replace("/Reporting\-MTA/", '', $result['email']);
}
/*
* rule: mailbox unknown;
* sample:
* A message that you sent could not be delivered to one or more of its^M
* recipients. This is a permanent error. The following address(es) failed:^M
* ^M
* xxxxx@yourdomain.com^M
* unknown local-part "xxxxx" in domain "yourdomain.com"^M
*/
elseif (preg_match("/(\S+@\S+\w).*\n?.*unknown local-part/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0232';
$result['email'] = $match[1];
}
/*
* rule: mailbox unknown;
* sample:
* <xxxxx@yourdomain.com>:^M
* 111.111.111.11 does not like recipient.^M
* Remote host said: 550 Invalid recipient: <xxxxx@yourdomain.com>^M
*/
elseif (preg_match("/Invalid.*(?:alias|account|recipient|address|email|mailbox|user).*<(\S+@\S+\w)>/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0233';
$result['email'] = $match[1];
}
/*
* rule: mailbox unknown;
* sample:
* Sent >>> RCPT TO: <xxxxx@yourdomain.com>^M
* Received <<< 550 xxxxx@yourdomain.com... No such user^M
* ^M
* Could not deliver mail to this user.^M
* xxxxx@yourdomain.com^M
* ***************** End of message ***************^M
*/
elseif (preg_match("/\s(\S+@\S+\w).*[\r\n]*.*No such.*(?:alias|account|recipient|address|email|mailbox|user)/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0234';
$result['email'] = $match[1];
}
/*
* rule: mailbox unknown;
* sample:
* <xxxxx@yourdomain.com>:^M
* This address no longer accepts mail.
*/
elseif (preg_match("/<(\S+@\S+\w)>.*\n?.*(?:alias|account|recipient|address|email|mailbox|user).*no.*accept.*mail>/i", $body, $match)) {
$result['rule_cat'] = Category::UNKNOWN;
$result['rule_no'] = '0235';
$result['email'] = $match[1];
}
/*
* rule: full
* sample 1:
* <xxxxx@yourdomain.com>:
* This account is over quota and unable to receive mail.
* sample 2:
* <xxxxx@yourdomain.com>:
* Warning: undefined mail delivery mode: normal (ignored).
* The users mailfolder is over the allowed quota (size). (#5.2.2)
*/
elseif (preg_match("/<(\S+@\S+\w)>.*\n?.*\n?.*over.*quota/i", $body, $match)) {
$result['rule_cat'] = Category::FULL;
$result['rule_no'] = '0182';
$result['email'] = $match[1];
}
/*
* rule: mailbox full;
* sample:
* ----- Transcript of session follows -----
* mail.local: /var/mail/2b/10/kellen.lee: Disc quota exceeded
* 554 <xxxxx@yourdomain.com>... Service unavailable
*/
elseif (preg_match("/quota exceeded.*\n?.*<(\S+@\S+\w)>/i", $body, $match)) {
$result['rule_cat'] = Category::FULL;
$result['rule_no'] = '0126';
$result['email'] = $match[1];
}
/*
* rule: mailbox full;
* sample:
* Hi. This is the qmail-send program at 263.domain.com.
* <xxxxx@yourdomain.com>:
* - User disk quota exceeded. (#4.3.0)
*/
elseif (preg_match("/<(\S+@\S+\w)>.*\n?.*quota exceeded/i", $body, $match)) {
$result['rule_cat'] = Category::FULL;
$result['rule_no'] = '0158';
$result['email'] = $match[1];
}
/*
* rule: mailbox full;
* sample:
* xxxxx@yourdomain.com
* mailbox is full (MTA-imposed quota exceeded while writing to file /mbx201/mbx011/A100/09/35/A1000935772/mail/.inbox):
*/
elseif (preg_match("/\s(\S+@\S+\w)\s.*\n?.*mailbox.*full/i", $body, $match)) {
$result['rule_cat'] = Category::FULL;
$result['rule_no'] = '0166';
$result['email'] = $match[1];
/*
* rule: mailbox full;
* sample:
* name@domain.com
* Delay reason: LMTP error after end of data: 452 4.2.2 <name@domain.com> Mailbox is full / Blocks limit exceeded / Inode limit exceeded
*/
} elseif (preg_match("/\s<(\S+@\S+\w)>\sMailbox.*full/i", $body, $match)) {
$result['rule_cat'] = Category::FULL;
$result['rule_no'] = '0166';
$result['email'] = $match[1];
}
/*
* rule: mailbox full;
* sample:
* The message to xxxxx@yourdomain.com is bounced because : Quota exceed the hard limit
*/
elseif (preg_match("/The message to (\S+@\S+\w)\s.*bounce.*Quota exceed/i", $body, $match)) {
$result['rule_cat'] = Category::FULL;
$result['rule_no'] = '0168';
$result['email'] = $match[1];
}
/*
* rule: inactive
* sample:
* xxxxx@yourdomain.com<br>
* 553 user is inactive (eyou mta)
*/
elseif (preg_match("/(\S+@\S+\w)<br>.*\n?.*\n?.*user is inactive/i", $body, $match)) {
$result['rule_cat'] = Category::INACTIVE;
$result['rule_no'] = '0171';
$result['email'] = $match[1];
}
/*
* rule: inactive
* sample:
* xxxxx@yourdomain.com [Inactive account]
*/
elseif (preg_match("/(\S+@\S+\w).*inactive account/i", $body, $match)) {
$result['rule_cat'] = Category::INACTIVE;
$result['rule_no'] = '0181';
$result['email'] = $match[1];
}
/*
* rule: internal_error
* sample:
* <xxxxx@yourdomain.com>:
* Unable to switch to /var/vpopmail/domains/domain.com: input/output error. (#4.3.0)
*/
elseif (preg_match("/<(\S+@\S+\w)>.*\n?.*input\/output error/i", $body, $match)) {
$result['rule_cat'] = Category::INTERNAL_ERROR;
$result['rule_no'] = '0172';
$result['bounce_type'] = Type::HARD;
$result['remove'] = 1;
$result['email'] = $match[1];
}
/*
* rule: internal_error
* sample:
* <xxxxx@yourdomain.com>:
* can not open new email file errno=13 file=/home/vpopmail/domains/fromc.com/0/domain/Maildir/tmp/1155254417.28358.mx05,S=212350
*/
elseif (preg_match("/<(\S+@\S+\w)>.*\n?.*can not open new email file/i", $body, $match)) {
$result['rule_cat'] = Category::INTERNAL_ERROR;
$result['rule_no'] = '0173';
$result['bounce_type'] = Type::HARD;
$result['remove'] = 1;
$result['email'] = $match[1];
}
/*
* rule: defer
* sample:
* <xxxxx@yourdomain.com>:
* 111.111.111.111 failed after I sent the message.
* Remote host said: 451 mta283.mail.scd.yahoo.com Resources temporarily unavailable. Please try again later [#4.16.5].
*/
elseif (preg_match("/<(\S+@\S+\w)>.*\n?.*\n?.*Resources temporarily unavailable/i", $body, $match)) {
$result['rule_cat'] = Category::DEFER;
$result['rule_no'] = '0163';
$result['email'] = $match[1];
}
/*
* rule: autoreply
* sample:
* AutoReply message from xxxxx@yourdomain.com
*/
elseif (preg_match("/^AutoReply message from (\S+@\S+\w)/i", $body, $match)) {
$result['rule_cat'] = Category::AUTOREPLY;
$result['rule_no'] = '0167';
$result['email'] = $match[1];
}
/*
* rule: western chars only
* sample:
* <xxxxx@yourdomain.com>:
* The user does not accept email in non-Western (non-Latin) character sets.
*/
elseif (preg_match("/<(\S+@\S+\w)>.*\n?.*does not accept[^\r\n]*non-Western/i", $body, $match)) {
$result['rule_cat'] = Category::LATIN_ONLY;
$result['rule_no'] = '0043';
$result['email'] = $match[1];
}
if (false === $result['bounce_type']) {
$categoryObject = CategoryMapper::map($result['rule_cat']);
$result['bounce_type'] = $categoryObject->getType();
$result['remove'] = $categoryObject->isPermanent();
}
return $result;
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Bounce;
class BouncedEmail
{
/**
* @var string|null
*/
private $email;
/**
* @var string|null
*/
private $ruleCategory;
/**
* @var string|null
*/
private $ruleNumber;
/**
* @var string|null
*/
private $bounceType;
private bool $final = false;
/**
* @var string|null
*/
private $bounceAddress;
/**
* @return string
*/
public function getContactEmail()
{
return $this->email;
}
/**
* @param string $email
*
* @return BouncedEmail
*/
public function setContactEmail($email)
{
$this->email = $email;
return $this;
}
/**
* @return string
*/
public function getRuleCategory()
{
return $this->ruleCategory;
}
/**
* @param string $ruleCategory
*
* @return BouncedEmail
*/
public function setRuleCategory($ruleCategory)
{
$this->ruleCategory = $ruleCategory;
return $this;
}
/**
* @return string
*/
public function getRuleNumber()
{
return $this->ruleNumber;
}
/**
* @param string $ruleNumber
*
* @return BouncedEmail
*/
public function setRuleNumber($ruleNumber)
{
$this->ruleNumber = $ruleNumber;
return $this;
}
/**
* @return string
*/
public function getType()
{
return $this->bounceType;
}
/**
* @param mixed $bounceType
*
* @return BouncedEmail
*/
public function setType($bounceType)
{
$this->bounceType = $bounceType;
return $this;
}
public function isFinal(): bool
{
return $this->final;
}
/**
* @param bool $final
*
* @return BouncedEmail
*/
public function setIsFinal($final)
{
$this->final = (bool) $final;
return $this;
}
/**
* @return string
*/
public function getBounceAddress()
{
return $this->bounceAddress;
}
/**
* @return BouncedEmail
*/
public function setBounceAddress($bounceAddress)
{
$this->bounceAddress = $bounceAddress;
return $this;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition;
final class Category
{
/**
* Message rejected due to spam or anti-abuse filters (e.g., sender blocked, spam detected, blacklists).
*/
public const ANTISPAM = 'antispam';
/**
* Message is an auto-reply (e.g., out of office, vacation response).
*/
public const AUTOREPLY = 'autoreply';
/**
* Concurrent delivery issues (e.g., too many connections or sessions).
*/
public const CONCURRENT = 'concurrent';
/**
* Message rejected due to content issues (e.g., invalid MIME, message structure, or content policy).
*/
public const CONTENT_REJECT = 'content_reject';
/**
* Message rejected due to command or protocol errors (e.g., relay not permitted, authentication failed).
*/
public const COMMAND_REJECT = 'command_reject';
/**
* Internal server error or misconfiguration (e.g., I/O error, system config error).
*/
public const INTERNAL_ERROR = 'internal_error';
/**
* Temporary delivery failure, message may be retried (e.g., system busy, resources unavailable).
*/
public const DEFER = 'defer';
/**
* Delivery delayed, message not yet permanently failed (e.g., delivery temporarily suspended).
*/
public const DELAYED = 'delayed';
/**
* DNS configuration loop detected (e.g., MX points back to sender, mail loop).
*/
public const DNS_LOOP = 'dns_loop';
/**
* DNS or domain-related failure (e.g., host unknown, domain not found, no route to host).
*/
public const DNS_UNKNOWN = 'dns_unknown';
/**
* Recipient's mailbox is full or over quota.
*/
public const FULL = 'full';
/**
* Recipient account is inactive, suspended, expired, or closed due to inactivity.
*/
public const INACTIVE = 'inactive';
/**
* Message rejected due to non-Latin characters or encoding issues.
*/
public const LATIN_ONLY = 'latin_only';
/**
* Other or uncategorized bounce reason.
*/
public const OTHER = 'other';
/**
* Message rejected due to size limits (e.g., message too large, exceeds system limit).
*/
public const OVERSIZE = 'oversize';
/**
* Out of office or auto-reply.
*/
public const OUTOFOFFICE = 'outofoffice';
/**
* Unknown recipient or address (e.g., user unknown, invalid address, not listed).
*/
public const UNKNOWN = 'unknown';
/**
* Bounce reason could not be recognized or parsed.
*/
public const UNRECOGNIZED = 'unrecognized';
/**
* Message rejected by recipient (e.g., user refused, sender not allowed).
*/
public const USER_REJECT = 'user_reject';
/**
* Warning or non-fatal issue (e.g., soft bounce, warning notification).
*/
public const WARNING = 'warning';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition;
/**
* @todo - define rule numbers from BodyParser and DsnParser
*/
final class Rule
{
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition;
final class Type
{
public const AUTOREPLY = 'autoreply';
public const BLOCKED = 'blocked';
public const HARD = 'hard';
public const GENERIC = 'generic';
public const UNKNOWN = 'unknown';
public const UNRECOGNIZED = 'unrecognized';
public const SOFT = 'soft';
public const TEMPORARY = 'temporary';
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Mapper;
class Category
{
/**
* @param string $category
* @param string $type
* @param bool $isPermanent
*/
public function __construct(
private $category,
private $type,
private $isPermanent,
) {
return $this;
}
/**
* @return string
*/
public function getCategory()
{
return $this->category;
}
/**
* @return string
*/
public function getType()
{
return $this->type;
}
/**
* @return bool
*/
public function isPermanent()
{
return $this->isPermanent;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Mapper;
use Mautic\EmailBundle\MonitoredEmail\Exception\CategoryNotFound;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition\Category;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition\Type;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Mapper\Category as CategoryObject;
class CategoryMapper
{
/**
* @var array
*/
protected static $mappings = [
Category::ANTISPAM => ['permanent' => false, 'bounce_type' => Type::BLOCKED],
Category::AUTOREPLY => ['permanent' => false, 'bounce_type' => Type::AUTOREPLY],
Category::CONCURRENT => ['permanent' => false, 'bounce_type' => Type::SOFT],
Category::CONTENT_REJECT => ['permanent' => false, 'bounce_type' => Type::SOFT],
Category::COMMAND_REJECT => ['permanent' => true, 'bounce_type' => Type::HARD],
Category::INTERNAL_ERROR => ['permanent' => false, 'bounce_type' => Type::TEMPORARY],
Category::DEFER => ['permanent' => false, 'bounce_type' => Type::SOFT],
Category::DELAYED => ['permanent' => false, 'bounce_type' => Type::TEMPORARY],
Category::DNS_LOOP => ['permanent' => true, 'bounce_type' => Type::HARD],
Category::DNS_UNKNOWN => ['permanent' => true, 'bounce_type' => Type::HARD],
Category::FULL => ['permanent' => false, 'bounce_type' => Type::SOFT],
Category::INACTIVE => ['permanent' => true, 'bounce_type' => Type::HARD],
Category::LATIN_ONLY => ['permanent' => false, 'bounce_type' => Type::SOFT],
Category::OTHER => ['permanent' => true, 'bounce_type' => Type::GENERIC],
Category::OVERSIZE => ['permanent' => false, 'bounce_type' => Type::SOFT],
Category::OUTOFOFFICE => ['permanent' => false, 'bounce_type' => Type::SOFT],
Category::UNKNOWN => ['permanent' => true, 'bounce_type' => Type::HARD],
Category::UNRECOGNIZED => ['permanent' => true, 'bounce_type' => Type::HARD],
Category::USER_REJECT => ['permanent' => true, 'bounce_type' => Type::HARD],
Category::WARNING => ['permanent' => false, 'bounce_type' => Type::SOFT],
];
/**
* @throws CategoryNotFound
*/
public static function map($category): CategoryObject
{
if (!isset(static::$mappings[$category])) {
throw new CategoryNotFound();
}
$mapping = static::$mappings[$category];
return new CategoryObject($category, $mapping['bounce_type'], $mapping['permanent']);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Bounce;
use Mautic\EmailBundle\MonitoredEmail\Exception\BounceNotFound;
use Mautic\EmailBundle\MonitoredEmail\Message;
class Parser
{
public function __construct(
private Message $message,
) {
}
/**
* @return string|null
*/
public function getFailedRecipients()
{
return $this->message->xHeaders['x-failed-recipients'] ?? null;
}
/**
* @return BouncedEmail
*
* @throws BounceNotFound
*/
public function parse()
{
$bouncerAddress = null;
foreach ($this->message->to as $to => $name) {
// Some ISPs strip the + email so will still process the content for a bounce
// even if a +bounce address was not found
if (str_contains($to, '+bounce')) {
$bouncerAddress = $to;
break;
}
}
// First parse for a DSN report
$dsnParser = new DsnParser();
try {
$bounce = $dsnParser->getBounce($this->message);
} catch (BounceNotFound) {
// DSN report wasn't found so try parsing the body itself
$bodyParser = new BodyParser();
$bounce = $bodyParser->getBounce($this->message, $this->getFailedRecipients());
}
$bounce->setBounceAddress($bouncerAddress);
return $bounce;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor;
use Mautic\EmailBundle\MonitoredEmail\Exception\FeedbackLoopNotFound;
use Mautic\EmailBundle\MonitoredEmail\Message;
use Mautic\EmailBundle\MonitoredEmail\Processor\FeedbackLoop\Parser;
use Mautic\EmailBundle\MonitoredEmail\Search\ContactFinder;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Model\DoNotContact as DoNotContactModel;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class FeedbackLoop implements ProcessorInterface
{
private ?Message $message = null;
public function __construct(
private ContactFinder $contactFinder,
private TranslatorInterface $translator,
private LoggerInterface $logger,
private DoNotContactModel $doNotContact,
) {
}
public function process(Message $message): bool
{
$this->message = $message;
$this->logger->debug('MONITORED EMAIL: Processing message ID '.$this->message->id.' for a feedback loop report');
if (!$this->isApplicable()) {
return false;
}
try {
$parser = new Parser($this->message);
if (!$contactEmail = $parser->parse()) {
// A contact email was not found in the FBL report
return false;
}
} catch (FeedbackLoopNotFound) {
return false;
}
$this->logger->debug('MONITORED EMAIL: Found '.$contactEmail.' in feedback loop report');
$searchResult = $this->contactFinder->find($contactEmail);
if (!$contacts = $searchResult->getContacts()) {
return false;
}
$comments = $this->translator->trans('mautic.email.bounce.reason.spam');
foreach ($contacts as $contact) {
$this->doNotContact->addDncForContact($contact->getId(), 'email', DoNotContact::UNSUBSCRIBED, $comments);
}
return true;
}
protected function isApplicable(): int|bool
{
return preg_match('/.*feedback-type: abuse.*/is', $this->message->fblReport);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\FeedbackLoop;
use Mautic\EmailBundle\MonitoredEmail\Exception\FeedbackLoopNotFound;
use Mautic\EmailBundle\MonitoredEmail\Message;
use Mautic\EmailBundle\MonitoredEmail\Processor\Address;
class Parser
{
public function __construct(
private Message $message,
) {
}
/**
* @return string|null
*
* @throws FeedbackLoopNotFound
*/
public function parse()
{
if (null === $this->message->fblReport) {
throw new FeedbackLoopNotFound();
}
if ($email = $this->searchMessage('Original-Rcpt-To: (.*)', $this->message->fblReport)) {
return $email;
}
if ($email = $this->searchMessage('Received:.*for (.*);.*?', $this->message->textPlain)) {
return $email;
}
throw new FeedbackLoopNotFound();
}
protected function searchMessage(string $pattern, string $content): ?string
{
if (preg_match('/'.$pattern.'/i', $content, $match)) {
if ($parsedAddressList = Address::parseList($match[1])) {
return key($parsedAddressList);
}
}
return null;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor;
use Mautic\EmailBundle\MonitoredEmail\Message;
interface ProcessorInterface
{
/**
* Process the message.
*
* @return bool|void
*/
public function process(Message $message);
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor;
use Doctrine\ORM\EntityNotFoundException;
use Mautic\CoreBundle\Helper\EmailAddressHelper;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Entity\EmailReply;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Event\EmailReplyEvent;
use Mautic\EmailBundle\Model\EmailStatModel;
use Mautic\EmailBundle\MonitoredEmail\Exception\ReplyNotFound;
use Mautic\EmailBundle\MonitoredEmail\Message;
use Mautic\EmailBundle\MonitoredEmail\Processor\Reply\Parser;
use Mautic\EmailBundle\MonitoredEmail\Search\ContactFinder;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class Reply implements ProcessorInterface
{
public function __construct(
private EmailStatModel $emailStatModel,
private ContactFinder $contactFinder,
private LeadModel $leadModel,
private EventDispatcherInterface $dispatcher,
private LoggerInterface $logger,
private ContactTracker $contactTracker,
private EmailAddressHelper $addressHelper,
) {
}
public function process(Message $message): void
{
$this->logger->debug('MONITORED EMAIL: Processing message ID '.$message->id.' for a reply');
try {
$parser = new Parser($message);
$repliedEmail = $parser->parse();
} catch (ReplyNotFound) {
// No hash found so bail as we won't consider this a reply
$this->logger->debug('MONITORED EMAIL: No hash ID found in the email body');
return;
}
$hashId = $repliedEmail->getStatHash();
$result = $this->contactFinder->findByHash($hashId);
if (!$stat = $result->getStat()) {
// No stat found so bail as we won't consider this a reply
$this->logger->debug('MONITORED EMAIL: Stat not found');
return;
}
// A stat has been found so let's compare to the From address for the contact to prevent false positives
$possibleFromEmails = $this->addressHelper->getVariations($stat->getLead()->getEmail());
$fromEmail = $this->addressHelper->cleanEmail($repliedEmail->getFromAddress());
if (!in_array($fromEmail, $possibleFromEmails)) {
// We can't reliably assume this email was from the originating contact
$this->logger->debug('MONITORED EMAIL: '.implode(', ', $possibleFromEmails).' != '.$fromEmail.' so cannot confirm match');
return;
}
$this->createReply($stat, $message->id);
$this->dispatchEvent($stat);
if (null !== $stat->getLead()) {
$this->leadModel->getRepository()->detachEntity($stat->getLead());
}
$this->emailStatModel->getRepository()->detachEntity($stat);
}
/**
* @param string $trackingHash
* @param string $messageId
*/
public function createReplyByHash($trackingHash, $messageId): void
{
/** @var Stat|null $stat */
$stat = $this->emailStatModel->getRepository()->findOneBy(['trackingHash' => $trackingHash]);
if (null === $stat) {
throw new EntityNotFoundException("Email Stat with tracking hash {$trackingHash} was not found");
}
$stat->setIsRead(true);
if (null === $stat->getDateRead()) {
$stat->setDateRead(new \DateTime());
}
$this->createReply($stat, $messageId);
$contact = $stat->getLead();
if ($contact) {
$this->dispatchEvent($stat);
}
}
/**
* @param string $messageId
*/
protected function createReply(Stat $stat, $messageId)
{
$replies = $stat->getReplies()->filter(
fn (EmailReply $reply): bool => $reply->getMessageId() === $messageId
);
if (!$replies->count()) {
$emailReply = new EmailReply($stat, $messageId);
$stat->addReply($emailReply);
$this->emailStatModel->saveEntity($stat);
}
}
private function dispatchEvent(Stat $stat): void
{
if ($this->dispatcher->hasListeners(EmailEvents::EMAIL_ON_REPLY)) {
$this->contactTracker->setTrackedContact($stat->getLead());
$event = new EmailReplyEvent($stat);
$this->dispatcher->dispatch($event, EmailEvents::EMAIL_ON_REPLY);
unset($event);
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Reply;
use Mautic\EmailBundle\MonitoredEmail\Exception\ReplyNotFound;
use Mautic\EmailBundle\MonitoredEmail\Message;
class Parser
{
public function __construct(
private Message $message,
) {
}
/**
* Only sure way is to parse the content for the stat ID otherwise attempt the from.
*
* @throws ReplyNotFound
*/
public function parse(): RepliedEmail
{
if (!preg_match('/email\/([a-zA-Z0-9]+)\.gif/', $this->message->textHtml, $parts)) {
throw new ReplyNotFound();
}
$hashId = $parts[1];
return new RepliedEmail($this->message->fromAddress, $hashId);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Reply;
class RepliedEmail
{
/**
* @param string $fromAddress
*/
public function __construct(
private $fromAddress,
private $statHash = null,
) {
}
/**
* @return string
*/
public function getFromAddress()
{
return $this->fromAddress;
}
/**
* @return string|null
*/
public function getStatHash()
{
return $this->statHash;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor;
use Mautic\EmailBundle\Mailer\Transport\UnsubscriptionProcessorInterface;
use Mautic\EmailBundle\MonitoredEmail\Exception\UnsubscriptionNotFound;
use Mautic\EmailBundle\MonitoredEmail\Message;
use Mautic\EmailBundle\MonitoredEmail\Processor\Unsubscription\Parser;
use Mautic\EmailBundle\MonitoredEmail\Search\ContactFinder;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Model\DoNotContact as DoNotContactModel;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class Unsubscribe implements ProcessorInterface
{
private ?Message $message = null;
public function __construct(
private TransportInterface $transport,
private ContactFinder $contactFinder,
private TranslatorInterface $translator,
private LoggerInterface $logger,
private DoNotContactModel $doNotContact,
) {
}
public function process(Message $message): bool
{
$this->message = $message;
$this->logger->debug('MONITORED EMAIL: Processing message ID '.$this->message->id.' for an unsubscription');
$unsubscription = false;
// Does the transport have special handling like Amazon SNS
if ($this->transport instanceof UnsubscriptionProcessorInterface) {
try {
$unsubscription = $this->transport->processUnsubscription($this->message);
} catch (UnsubscriptionNotFound) {
// Attempt to parse a unsubscription the standard way
}
}
if (!$unsubscription) {
try {
$parser = new Parser($message);
$unsubscription = $parser->parse();
} catch (UnsubscriptionNotFound) {
// No stat found so bail as we won't consider this a reply
$this->logger->debug('MONITORED EMAIL: Unsubscription email was not found');
return false;
}
}
$searchResult = $this->contactFinder->find($unsubscription->getContactEmail(), $unsubscription->getUnsubscriptionAddress());
if (!$contacts = $searchResult->getContacts()) {
// No contacts found so bail
return false;
}
$stat = $searchResult->getStat();
$channel = 'email';
if ($stat && $email = $stat->getEmail()) {
// We know the email ID so set it to append to the the DNC record
$channel = ['email' => $email->getId()];
}
$comments = $this->translator->trans('mautic.email.bounce.reason.unsubscribed');
foreach ($contacts as $contact) {
$this->doNotContact->addDncForContact($contact->getId(), $channel, DoNotContact::UNSUBSCRIBED, $comments);
}
return true;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Unsubscription;
use Mautic\EmailBundle\MonitoredEmail\Exception\UnsubscriptionNotFound;
use Mautic\EmailBundle\MonitoredEmail\Message;
class Parser
{
public function __construct(
protected Message $message,
) {
}
/**
* @throws UnsubscriptionNotFound
*/
public function parse(): UnsubscribedEmail
{
$unsubscriptionEmail = null;
foreach ($this->message->to as $to => $name) {
if (str_contains($to, '+unsubscribe')) {
$unsubscriptionEmail = $to;
break;
}
}
if (!$unsubscriptionEmail) {
throw new UnsubscriptionNotFound();
}
return new UnsubscribedEmail($this->message->fromAddress, $unsubscriptionEmail);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Unsubscription;
class UnsubscribedEmail
{
/**
* @param string $contactEmail
* @param string $unsubscriptionAddress
*/
public function __construct(
private $contactEmail,
private $unsubscriptionAddress,
) {
}
/**
* @return string
*/
public function getContactEmail()
{
return $this->contactEmail;
}
/**
* @return string
*/
public function getUnsubscriptionAddress()
{
return $this->unsubscriptionAddress;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Search;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\EmailBundle\Entity\StatRepository;
use Mautic\EmailBundle\MonitoredEmail\Processor\Address;
use Mautic\LeadBundle\Entity\LeadRepository;
use Psr\Log\LoggerInterface;
class ContactFinder
{
public function __construct(
private StatRepository $statRepository,
private LeadRepository $leadRepository,
private LoggerInterface $logger,
) {
}
/**
* @param string $returnPathEmail
*
* @return Result
*/
public function find($contactEmail, $returnPathEmail = null)
{
$this->logger->debug("MONITORED EMAIL: Searching for a contact $contactEmail/$returnPathEmail");
// We have a return path email so let's figure out who it originated to
if ($returnPathEmail && $hash = Address::parseAddressForStatHash($returnPathEmail)) {
$result = $this->findByHash($hash);
if ($result->getStat()) {
// A stat was found so need to search by email
return $result;
}
}
return $this->findByAddress($contactEmail);
}
/**
* @param string $hash
*/
public function findByHash($hash): Result
{
$result = new Result();
$this->logger->debug('MONITORED EMAIL: Searching for a contact by hash '.$hash);
/** @var Stat $stat */
$stat = $this->statRepository->findOneBy(['trackingHash' => $hash]);
$this->logger->debug("MONITORED EMAIL: HashId of $hash found in return path");
if ($stat && $stat->getLead()) {
$this->logger->debug("MONITORED EMAIL: Stat ID {$stat->getId()} found for hash $hash");
$result->setStat($stat);
}
return $result;
}
public function findByAddress($address): Result
{
$result = new Result();
// Search by email address
if ($contacts = $this->leadRepository->getContactsByEmail($address)) {
$result->setContacts($contacts);
$this->logger->debug('MONITORED EMAIL: '.count($contacts).' contacts found');
}
return $result;
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Mautic\EmailBundle\MonitoredEmail\Search;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\LeadBundle\Entity\Lead;
class Result
{
private ?Stat $stat = null;
/**
* @var Lead[]
*/
private array $contacts = [];
/**
* @var string
*/
private $email;
/**
* @return Stat
*/
public function getStat()
{
return $this->stat;
}
/**
* @return Result
*/
public function setStat(Stat $stat)
{
$this->stat = $stat;
if ($contact = $stat->getLead()) {
$this->contacts[] = $contact;
}
return $this;
}
/**
* @return Lead[]
*/
public function getContacts()
{
return $this->contacts;
}
/**
* @return Result
*/
public function addContact(Lead $contact)
{
$this->contacts[] = $contact;
return $this;
}
/**
* @param Lead[] $contacts
*/
public function setContacts(array $contacts): void
{
$this->contacts = $contacts;
}
/**
* @return mixed
*/
public function getEmail()
{
return $this->email;
}
/**
* @param mixed $email
*
* @return Result
*/
public function setEmail($email)
{
$this->email = $email;
return $this;
}
}