Initial commit

This commit is contained in:
gustavooth
2026-07-23 22:56:30 -03:00
commit 22a1006598
1816 changed files with 410742 additions and 0 deletions
@@ -0,0 +1,648 @@
<?php
namespace FluentCrm\App\Services\ExternalIntegrations\MailComplaince;
use FluentCrm\App\Hooks\Handlers\ExternalPages;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
class Webhook
{
/**
* @param $serviceName
* @param $request \FluentCrm\Framework\Http\Request\Request
*/
public function handle($serviceName, $request)
{
$method = 'handle' . ucfirst(strtolower($serviceName));
if (method_exists($this, $method)) {
return $this->{$method}($request);
}
return null;
}
/**
* @param $request \FluentCrm\Framework\Http\Request\Request
*/
private function handleMailgun($request)
{
$payload = $this->resolvePayload($request, []);
$eventData = Arr::get($payload, 'event-data', []);
if (!$eventData) {
// Fallback: try reading from request params directly
$eventData = $request->get('event-data', []);
}
if (!$eventData) {
return false;
}
$event = Arr::get($eventData, 'event');
$catchEvents = ['failed', 'unsubscribed', 'complained'];
if (!in_array($event, $catchEvents)) {
return false;
}
$recipientEmail = Arr::get($eventData, 'recipient');
if (!$recipientEmail) {
return false;
}
$externalPages = new ExternalPages();
// For failed events, check severity to distinguish soft vs hard bounce
if ($event == 'failed') {
$severity = Arr::get($eventData, 'severity', 'permanent');
if ($severity == 'temporary') {
$description = Arr::get($eventData, 'delivery-status.message', '');
if (!$description) {
$description = Arr::get($eventData, 'delivery-status.description', '');
}
return $externalPages->recordSoftBounce([
'email' => $recipientEmail,
'reason' => __('Soft bounce was set by Mailgun webhook API.', 'fluent-crm') . ' ' . $description . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
]);
}
}
$newStatus = 'bounced';
if ($event == 'complained') {
$newStatus = 'complained';
} else if ($event == 'unsubscribed') {
$newStatus = 'unsubscribed';
}
$unsubscribeData = [
'email' => $recipientEmail,
'reason' => $newStatus . __(' was set by Mailgun webhook API with event name: ', 'fluent-crm') . $event . __(' at ', 'fluent-crm') . current_time('mysql'),
'status' => $newStatus
];
return $externalPages->recordUnsubscribe($unsubscribeData);
}
/**
* @param $request \FluentCrm\Framework\Http\Request\Request
* @return boolean
*/
private function handleSendgrid($request)
{
$events = $this->resolvePayload($request, []);
if (!$events || !count($events)) {
return false;
}
$externalPages = new ExternalPages();
$processed = false;
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$eventName = Arr::get($event, 'event');
$email = Arr::get($event, 'email');
if (!$email || !in_array($eventName, ['dropped', 'bounce', 'spamreport', 'unsubscribe'])) {
continue;
}
if ($eventName == 'unsubscribe') {
$externalPages->recordUnsubscribe([
'email' => $email,
'reason' => __('unsubscribed status was set from SendGrid Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
'status' => 'unsubscribed'
]);
$processed = true;
continue;
}
if ($eventName == 'spamreport') {
$externalPages->recordUnsubscribe([
'email' => $email,
'reason' => __('complained status was set from SendGrid Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
'status' => 'complained'
]);
$processed = true;
continue;
}
// bounce or dropped — check type field for soft vs hard bounce
$bounceType = Arr::get($event, 'type', 'bounce');
$reason = Arr::get($event, 'reason', '');
if ($bounceType == 'blocked') {
$externalPages->recordSoftBounce([
'email' => $email,
'reason' => __('Soft bounce from SendGrid Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
]);
} else {
$externalPages->recordUnsubscribe([
'email' => $email,
'reason' => __('bounced status was set from SendGrid Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
'status' => 'bounced'
]);
}
$processed = true;
}
return $processed;
}
/**
* @param $request \FluentCrm\Framework\Http\Request\Request
* @return boolean
*/
private function handlePepipost($request)
{
$events = $this->resolvePayload($request, []);
if (!$events || !count($events)) {
return false;
}
$externalPages = new ExternalPages();
$processed = false;
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$eventName = Arr::get($event, 'EVENT');
if (!in_array($eventName, ['bounced', 'invalid', 'spam', 'unsubscribed'])) {
continue;
}
$newStatus = 'bounced';
if ($eventName == 'unsubscribed') {
$newStatus = 'unsubscribed';
} else if ($eventName == 'spam') {
$newStatus = 'complained';
}
$reason = $newStatus . __(' status was set from Pepipost Webhook API. Reason: ', 'fluent-crm') . Arr::get($event, 'BOUNCE_TYPE') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql');
if ($sourceResponse = Arr::get($event, 'RESPONSE')) {
$reason = $sourceResponse;
}
$email = Arr::get($event, 'EMAIL');
if ($email) {
$externalPages->recordUnsubscribe([
'email' => $email,
'reason' => $reason,
'status' => $newStatus
]);
$processed = true;
}
}
return $processed;
}
/**
* @param $request \FluentCrm\Framework\Http\Request\Request
* @return boolean
*/
private function handleSparkpost($request)
{
$events = $this->resolvePayload($request, []);
if (!$events || !count($events)) {
return false;
}
$externalPages = new ExternalPages();
$processed = false;
// SparkPost hard bounce classes: 10 (Invalid Recipient), 25 (Admin Failure),
// 30 (Generic Bounce: No RCPT), 90 (Unsubscribe)
$hardBounceClasses = [10, 25, 30, 90];
foreach ($events as $eventWrapper) {
if (!is_array($eventWrapper)) {
continue;
}
// SparkPost wraps events in msys.message_event or msys.unsubscribe_event
$event = Arr::get($eventWrapper, 'msys.message_event');
if (!$event || !is_array($event)) {
$event = Arr::get($eventWrapper, 'msys.unsubscribe_event');
}
if (!$event || !is_array($event)) {
continue;
}
$eventName = Arr::get($event, 'type');
if (!in_array($eventName, ['bounce', 'out_of_band', 'spam_complaint', 'link_unsubscribe', 'list_unsubscribe'])) {
continue;
}
$email = Arr::get($event, 'rcpt_to');
if (!$email) {
continue;
}
$reason = Arr::get($event, 'raw_reason', '');
if (!$reason) {
$reason = $eventName . __(' status was set from SparkPost Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql');
}
// Both bounce and out_of_band events carry bounce_class
if (in_array($eventName, ['bounce', 'out_of_band'])) {
$bounceClass = (int)Arr::get($event, 'bounce_class', 0);
if (!in_array($bounceClass, $hardBounceClasses)) {
$externalPages->recordSoftBounce([
'email' => $email,
'reason' => $reason
]);
$processed = true;
continue;
}
}
$newStatus = 'bounced';
if (in_array($eventName, ['link_unsubscribe', 'list_unsubscribe'])) {
$newStatus = 'unsubscribed';
} else if ($eventName == 'spam_complaint') {
$newStatus = 'complained';
}
$externalPages->recordUnsubscribe([
'email' => $email,
'reason' => $reason,
'status' => $newStatus
]);
$processed = true;
}
return $processed;
}
/**
* @param $request \FluentCrm\Framework\Http\Request\Request
* @return boolean
*/
private function handlePostmark($request)
{
$event = $this->resolvePayload($request, []);
if (!$event || !is_array($event)) {
return false;
}
$eventName = Arr::get($event, 'RecordType');
if (!in_array($eventName, ['Bounce', 'SpamComplaint'])) {
return false;
}
$email = Arr::get($event, 'Email');
if (!$email) {
return false;
}
$reason = Arr::get($event, 'Description', '');
if (!$reason) {
$reason = $eventName . __(' status was set from Postmark Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql');
}
$externalPages = new ExternalPages();
if ($eventName == 'SpamComplaint') {
return $externalPages->recordUnsubscribe([
'email' => $email,
'reason' => $reason,
'status' => 'complained'
]);
}
// For Bounce events, check Type to distinguish soft vs hard
$bounceType = Arr::get($event, 'Type', '');
if ($bounceType == 'SoftBounce' || $bounceType == 'Transient') {
return $externalPages->recordSoftBounce([
'email' => $email,
'reason' => $reason
]);
}
return $externalPages->recordUnsubscribe([
'email' => $email,
'reason' => $reason,
'status' => 'bounced'
]);
}
private function handleElasticemail($request)
{
$status = strtolower($request->get('status'));
$processStatuses = [
'error',
'abusereport',
'unsubscribed'
];
if (!in_array($status, $processStatuses)) {
return [
'message' => 'unknown_status'
];
}
$email = $request->get('to');
if (!$email) {
return false;
}
$externalPages = new ExternalPages();
$softBounceCategories = [
'AccountProblem',
'Throttled',
'SPFProblem',
'Timeout',
'ConnectionProblem',
'GreyListed',
'WhitelistingProblem',
'CodeError'
];
$category = $request->get('category', 'unknown');
if ($status == 'error') {
if (in_array($category, $softBounceCategories)) {
return $externalPages->recordSoftBounce([
'email' => $email,
'reason' => __('Soft bounce from ElasticEmail Webhook API. Category: ', 'fluent-crm') . $category . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
]);
}
return $externalPages->recordUnsubscribe([
'email' => $email,
'reason' => __('bounced status was set from ElasticEmail Webhook API. Category: ', 'fluent-crm') . $category . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
'status' => 'bounced'
]);
}
if ($status == 'abusereport') {
return $externalPages->recordUnsubscribe([
'email' => $email,
'reason' => __('complained status was set from ElasticEmail Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
'status' => 'complained'
]);
}
// unsubscribed
return $externalPages->recordUnsubscribe([
'email' => $email,
'reason' => __('unsubscribed status was set from ElasticEmail Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
'status' => 'unsubscribed'
]);
}
private function handlePostalserver($request)
{
$event = strtolower($request->get('event'));
$processStatuses = [
'messagebounced',
'messagedeliveryfailed',
'messagedelayed'
];
if (!in_array($event, $processStatuses)) {
return false;
}
$payload = $request->get('payload');
if (!$payload || !is_array($payload)) {
return false;
}
$externalPages = new ExternalPages();
if ($event == 'messagedeliveryfailed') {
$payloadStatus = Arr::get($payload, 'status');
$toEmail = Arr::get($payload, 'message.to');
$reason = Arr::get($payload, 'details', 'Unknown Reason');
if (!$toEmail || !is_email($toEmail)) {
return false;
}
// SoftFail → record as soft bounce
if ($payloadStatus != 'HardFail') {
return $externalPages->recordSoftBounce([
'email' => $toEmail,
'reason' => __('Soft bounce from PostalServer. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
]);
}
return $externalPages->recordUnsubscribe([
'email' => $toEmail,
'reason' => $reason,
'status' => 'bounced'
]);
}
if ($event == 'messagedelayed') {
$toEmail = Arr::get($payload, 'message.to');
$reason = Arr::get($payload, 'details', 'Unknown Reason');
if (!$toEmail || !is_email($toEmail)) {
return false;
}
return $externalPages->recordSoftBounce([
'email' => $toEmail,
'reason' => __('Soft bounce (delayed) from PostalServer. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
]);
}
// messagebounced — use original_message.to (not bounce.to which is the return-path)
$toEmail = Arr::get($payload, 'original_message.to');
if (!$toEmail || !is_email($toEmail)) {
return false;
}
$reason = __('Bounce notification received from PostalServer.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql');
return $externalPages->recordUnsubscribe([
'email' => $toEmail,
'reason' => $reason,
'status' => 'bounced'
]);
}
private function handleSmtp2go($request)
{
$event = strtolower($request->get('event'));
$processStatuses = [
'bounce',
'spam',
'unsubscribe'
];
if (!in_array($event, $processStatuses)) {
return false;
}
$toEmail = $request->get('rcpt');
if (!$toEmail || !is_email($toEmail)) {
return false;
}
$reason = sanitize_textarea_field($request->get('message', 'Unknown Reason'));
$externalPages = new ExternalPages();
if ($event == 'bounce') {
$bounceType = $request->get('bounce');
if ($bounceType == 'soft') {
return $externalPages->recordSoftBounce([
'email' => $toEmail,
'reason' => __('Soft bounce from SMTP2GO Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
]);
}
}
$newStatus = 'bounced';
if ($event == 'unsubscribe') {
$newStatus = 'unsubscribed';
} else if ($event == 'spam') {
$newStatus = 'complained';
}
return $externalPages->recordUnsubscribe([
'email' => $toEmail,
'reason' => $reason,
'status' => $newStatus
]);
}
/**
* @param $request \FluentCrm\Framework\Http\Request\Request
* @return boolean
*/
private function handleBrevo($request)
{
$event = $this->resolvePayload($request, []);
if (!$event || !count($event)) {
return false;
}
$eventName = Arr::get($event, 'event');
if (!in_array($eventName, ['soft_bounce', 'hard_bounce', 'invalid', 'invalid_email', 'spam', 'error', 'blocked', 'deferred', 'unsubscribe', 'unsubscribed'])) {
return false;
}
$email = Arr::get($event, 'email');
if (!$email) {
return false;
}
$externalPages = new ExternalPages();
$reason = Arr::get($event, 'reason', '');
// Soft bounces and deferred deliveries should be tracked separately
if (in_array($eventName, ['soft_bounce', 'deferred'])) {
$reasonPrefix = $eventName === 'deferred'
? __('Deferred delivery from Brevo Webhook API. Reason: ', 'fluent-crm')
: __('Soft bounce from Brevo Webhook API. Reason: ', 'fluent-crm');
return $externalPages->recordSoftBounce([
'email' => $email,
'reason' => $reasonPrefix . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
]);
}
$newStatus = 'bounced';
if (in_array($eventName, ['unsubscribe', 'unsubscribed'])) {
$newStatus = 'unsubscribed';
} else if ($eventName == 'spam') {
$newStatus = 'complained';
}
// hard_bounce, invalid, invalid_email, error, blocked → bounced
return $externalPages->recordUnsubscribe([
'email' => $email,
'reason' => $newStatus . __(' status was set from Brevo Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
'status' => $newStatus
]);
}
private function handleTosend($request)
{
$event = $this->resolvePayload($request, []);
if (!$event || !is_array($event)) {
return false;
}
$eventType = Arr::get($event, 'type');
if (!in_array($eventType, ['bounce', 'complaint'])) {
return false;
}
$email = Arr::get($event, 'email');
if (!$email) {
return false;
}
$externalPages = new ExternalPages();
if ($eventType == 'complaint') {
return $externalPages->recordUnsubscribe([
'email' => $email,
'reason' => __('Complaint received from ToSend Webhook API.', 'fluent-crm') . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
'status' => 'complained'
]);
}
// Bounce event — check is_hard_bounce flag
$reason = Arr::get($event, 'reason', 'Unknown');
$isHardBounce = Arr::get($event, 'is_hard_bounce', false);
// Sender-fault bounces (our content/message, not the recipient's mailbox) —
// do NOT penalise the subscriber. Prefer the explicit `sender_fault` flag;
// fall back to `bounce_sub_type` for older payloads.
$senderFaultSubTypes = ['MessageTooLarge', 'ContentRejected', 'AttachmentRejected'];
$isSenderFault = (bool) Arr::get($event, 'sender_fault', false)
|| (!$isHardBounce && in_array(Arr::get($event, 'bounce_sub_type'), $senderFaultSubTypes, true));
if ($isSenderFault) {
return true;
}
if (!$isHardBounce) {
return $externalPages->recordSoftBounce([
'email' => $email,
'reason' => __('Soft bounce from ToSend Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql')
]);
}
return $externalPages->recordUnsubscribe([
'email' => $email,
'reason' => __('Hard bounce from ToSend Webhook API. Reason: ', 'fluent-crm') . $reason . __(' Recorded at: ', 'fluent-crm') . current_time('mysql'),
'status' => 'bounced'
]);
}
private function resolvePayload($request, $default = [])
{
$contentPayload = Helper::parseArrayOrJson($request->getContent(), []);
if ($contentPayload) {
return $contentPayload;
}
return Helper::parseArrayOrJson($request->get(), $default);
}
}