Initial commit: CloudOps infrastructure platform
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\EmailBundle\MonitoredEmail;
|
||||
|
||||
class Attachment
|
||||
{
|
||||
public $id;
|
||||
|
||||
public $name;
|
||||
|
||||
public $filePath;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
|
||||
|
||||
class BounceNotFound extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
|
||||
|
||||
class CategoryNotFound extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
|
||||
|
||||
class FeedbackLoopNotFound extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
|
||||
|
||||
class NotConfiguredException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
|
||||
|
||||
class ReplyNotFound extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\EmailBundle\MonitoredEmail\Exception;
|
||||
|
||||
class UnsubscriptionNotFound extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\EmailBundle\MonitoredEmail\Processor\Bounce\Definition;
|
||||
|
||||
/**
|
||||
* @todo - define rule numbers from BodyParser and DsnParser
|
||||
*/
|
||||
final class Rule
|
||||
{
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user