invoiceninja/app/Jobs/SES/SESWebhook.php

661 lines
22 KiB
PHP

<?php
namespace App\Jobs\SES;
use App\Models\Company;
use App\Models\SystemLog;
use App\Libraries\MultiDB;
use Illuminate\Bus\Queueable;
use App\Jobs\Util\SystemLogger;
use App\Models\QuoteInvitation;
use App\Models\CreditInvitation;
use App\Models\InvoiceInvitation;
use Illuminate\Queue\SerializesModels;
use Turbo124\Beacon\Facades\LightLogs;
use App\Models\PurchaseOrderInvitation;
use Illuminate\Queue\InteractsWithQueue;
use App\Models\RecurringInvoiceInvitation;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\DataMapper\Analytics\Mail\EmailSpam;
use App\DataMapper\Analytics\Mail\EmailBounce;
use App\Notifications\Ninja\EmailSpamNotification;
use App\Notifications\Ninja\EmailBounceNotification;
class SESWebhook implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public $tries = 1;
public $invitation;
private $entity;
private array $default_response = [
'recipients' => '',
'subject' => 'Message not found.',
'entity' => '',
'entity_id' => '',
'events' => [],
'event_type' => 'unknown',
'timestamp' => '',
'message_id' => ''
];
private ?Company $company = null;
/**
* Create a new job instance.
*
*/
public function __construct(private array $request)
{
}
private function getSystemLog(string $message_id): ?SystemLog
{
return SystemLog::query()
->where('company_id', $this->invitation->company_id)
->where('type_id', SystemLog::TYPE_WEBHOOK_RESPONSE)
->where('category_id', SystemLog::CATEGORY_MAIL)
->where('log->MessageID', $message_id)
->orderBy('id', 'desc')
->first();
}
private function updateSystemLog(SystemLog $system_log, array $data): void
{
// Get existing log data or initialize empty array
$existing_log = $system_log->log ?? [];
$existing_history = $existing_log['history'] ?? [];
$existing_events = $existing_history['events'] ?? [];
// Get new event data from the current webhook
$new_event = $this->extractEventData();
// Check if this event type already exists in events array
$event_type = $this->getCurrentEventType();
$event_exists = false;
foreach ($existing_events as $event) {
if (isset($event['event_type']) && $event['event_type'] === $event_type) {
$event_exists = true;
break;
}
}
// Only add new event if this event type doesn't already exist
if (!$event_exists && !empty($new_event)) {
$existing_events[] = $new_event;
}
// Update the history with existing events plus any new event
$updated_history = array_merge($existing_history, [
'events' => $existing_events
]);
// Update the log with existing data plus updated history
$system_log->log = array_merge($existing_log, [
'history' => $updated_history,
'last_updated' => now()->toISOString(),
'last_event_type' => $event_type
]);
$system_log->save();
}
/**
* Get the current event type being processed
*/
private function getCurrentEventType(): string
{
$notification_type = $this->request['eventType'] ?? $this->request['Type'] ?? $this->request['notificationType'] ?? '';
switch ($notification_type) {
case 'Delivery':
case 'Received':
return 'delivery';
case 'Bounce':
return 'bounce';
case 'Complaint':
return 'complaint';
case 'Open':
case 'Rendering Failure':
return 'open';
default:
return 'unknown';
}
}
/**
* Extract company key from SES message tags or metadata
*/
private function extractCompanyKey(): ?string
{
// Check various possible locations for company key
// Check mail tags
if (isset($this->request['mail']['tags']['company_key'])) {
nlog("SESWebhook: Found company key in mail tags: " . $this->request['mail']['tags']['company_key']);
return $this->request['mail']['tags']['company_key'];
}
// Check email headers - specifically X-Tag which contains the company key
if (isset($this->request['mail']['headers'])) {
nlog("SESWebhook: Checking mail headers for X-Tag", [
'headers_count' => count($this->request['mail']['headers']),
'headers' => $this->request['mail']['headers']
]);
foreach ($this->request['mail']['headers'] as $header) {
if (isset($header['name']) && $header['name'] === 'X-Tag' && isset($header['value'])) {
nlog("SESWebhook: Found X-Tag header: " . $header['value']);
return $header['value'];
}
}
nlog("SESWebhook: X-Tag header not found in mail headers");
}
// Check common headers
if (isset($this->request['mail']['commonHeaders']['x-company-key'])) {
nlog("SESWebhook: Found company key in common headers: " . $this->request['mail']['commonHeaders']['x-company-key']);
return $this->request['mail']['commonHeaders']['x-company-key'];
}
// Check if company key is in the main request
if (isset($this->request['company_key'])) {
nlog("SESWebhook: Found company key in main request: " . $this->request['company_key']);
return $this->request['company_key'];
}
nlog("SESWebhook: No company key found in any location", [
'mail_headers_exists' => isset($this->request['mail']['headers']),
'mail_common_headers_exists' => isset($this->request['mail']['commonHeaders']),
'request_keys' => array_keys($this->request)
]);
return null;
}
/**
* Extract message ID from SES notification
*/
private function extractMessageId(): ?string
{
// Check various possible locations for message ID
if (isset($this->request['mail']['messageId'])) {
nlog("SESWebhook: Found message ID in mail: " . $this->request['mail']['messageId']);
return $this->request['mail']['messageId'];
}
if (isset($this->request['messageId'])) {
nlog("SESWebhook: Found message ID in main request: " . $this->request['messageId']);
return $this->request['messageId'];
}
nlog("SESWebhook: No message ID found in any location");
return null;
}
/**
* Execute the job.
*
*/
public function handle()
{
nlog("SESWebhook: Processing SES webhook data", ['request' => $this->request]);
// Extract company key from SES message tags or metadata
$company_key = $this->extractCompanyKey();
if (!$company_key) {
nlog("SESWebhook: No company key found in webhook data");
return;
}
MultiDB::findAndSetDbByCompanyKey($company_key);
$this->company = Company::query()->where('company_key', $company_key)->first();
if (!$this->company) {
nlog("SESWebhook: Company not found for key: " . $company_key);
return;
}
// Extract message ID from SES notification
$message_id = $this->extractMessageId();
if (!$message_id) {
nlog("SESWebhook: No message ID found in webhook data");
return;
}
$this->invitation = $this->discoverInvitation($message_id);
if (!$this->invitation) {
nlog("SESWebhook: No invitation found for message ID: " . $message_id);
return;
}
// Handle different SES notification types
$notification_type = $this->request['eventType'] ?? $this->request['Type'] ?? $this->request['notificationType'] ?? '';
switch ($notification_type) {
case 'Delivery':
case 'Received':
return $this->processDelivery();
case 'Bounce':
return $this->processBounce();
case 'Complaint':
return $this->processComplaint();
case 'Open':
case 'Rendering Failure':
return $this->processOpen();
default:
nlog("SESWebhook: Unknown notification type: " . $notification_type);
break;
}
}
/**
* Process email delivery confirmation
*/
private function processDelivery()
{
$this->invitation->email_status = 'delivered';
$this->invitation->saveQuietly();
$this->request['MessageID'] = $this->extractMessageId();
$data = array_merge($this->request, [
'history' => $this->fetchMessage(),
'MessageID' => $this->extractMessageId()
]);
$sl = $this->getSystemLog($this->extractMessageId());
if ($sl) {
$this->updateSystemLog($sl, $data);
return;
}
(
new SystemLogger(
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_DELIVERY,
SystemLog::TYPE_WEBHOOK_RESPONSE,
$this->invitation->contact->client,
$this->invitation->company
)
)->handle();
}
/**
* Process email bounce
*/
private function processBounce()
{
$this->invitation->email_status = 'bounced';
$this->invitation->saveQuietly();
// Check if this is a confirmation email bounce
$subject = $this->request['bounce']['bouncedRecipients'][0]['email'] ?? '';
if ($subject == ctrans('texts.confirmation_subject')) {
$this->company->notification(new EmailBounceNotification($subject))->ninja();
}
$bounce = new EmailBounce(
$this->extractCompanyKey(),
$this->request['mail']['source'] ?? '',
$this->extractMessageId()
);
LightLogs::create($bounce)->send();
$data = array_merge($this->request, [
'history' => $this->fetchMessage(),
'MessageID' => $this->extractMessageId()
]);
$sl = $this->getSystemLog($this->extractMessageId());
if ($sl) {
$this->updateSystemLog($sl, $data);
return;
}
(
new SystemLogger(
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_BOUNCED,
SystemLog::TYPE_WEBHOOK_RESPONSE,
$this->invitation->contact->client,
$this->invitation->company
)
)->handle();
}
/**
* Process spam complaint
*/
private function processComplaint()
{
$this->invitation->email_status = 'spam';
$this->invitation->saveQuietly();
if (config('ninja.notification.slack')) {
$this->company->notification(new EmailSpamNotification($this->company))->ninja();
}
$spam = new EmailSpam(
$this->extractCompanyKey(),
$this->request['mail']['source'] ?? '',
$this->extractMessageId()
);
LightLogs::create($spam)->send();
$data = array_merge($this->request, [
'history' => $this->fetchMessage(),
'MessageID' => $this->extractMessageId()
]);
$sl = $this->getSystemLog($this->extractMessageId());
if ($sl) {
$this->updateSystemLog($sl, $data);
return;
}
(
new SystemLogger(
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_SPAM_COMPLAINT,
SystemLog::TYPE_WEBHOOK_RESPONSE,
$this->invitation->contact->client,
$this->invitation->company
)
)->handle();
}
/**
* Process email open
*/
private function processOpen()
{
$this->invitation->opened_date = now();
$this->invitation->saveQuietly();
$data = array_merge($this->request, [
'history' => $this->fetchMessage(),
'MessageID' => $this->extractMessageId()
]);
$sl = $this->getSystemLog($this->extractMessageId());
if ($sl) {
$this->updateSystemLog($sl, $data);
return;
}
(
new SystemLogger(
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_MAIL_OPENED,
SystemLog::TYPE_WEBHOOK_RESPONSE,
$this->invitation->contact->client,
$this->invitation->company
)
)->handle();
}
/**
* Discover invitation by message ID
*/
private function discoverInvitation($message_id)
{
$invitation = false;
if ($invitation = InvoiceInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'invoice';
return $invitation;
} elseif ($invitation = QuoteInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'quote';
return $invitation;
} elseif ($invitation = RecurringInvoiceInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'recurring_invoice';
return $invitation;
} elseif ($invitation = CreditInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'credit';
return $invitation;
} elseif ($invitation = PurchaseOrderInvitation::where('message_id', $message_id)->first()) {
$this->entity = 'purchase_order';
return $invitation;
} else {
return $invitation;
}
}
/**
* Fetch message details and create response data
*/
private function fetchMessage(): array
{
$message_id = $this->extractMessageId();
if (strlen($message_id) < 1) {
return $this->default_response;
}
try {
// Extract information from SES webhook data
$recipients = $this->extractRecipients();
$subject = $this->extractSubject();
$events = $this->extractEvents();
$event_type = $this->getCurrentEventType();
return [
'recipients' => $recipients,
'subject' => $subject,
'entity' => $this->entity ?? '',
'entity_id' => $this->invitation->{$this->entity}->hashed_id ?? '',
'events' => [$this->extractEventData()], // Start with single event in array
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
} catch (\Exception $e) {
nlog("SESWebhook: Error fetching message: " . $e->getMessage());
return $this->default_response;
}
}
/**
* Extract recipients from SES webhook data
*/
private function extractRecipients(): string
{
if (isset($this->request['mail']['destination'])) {
return is_array($this->request['mail']['destination'])
? implode(',', $this->request['mail']['destination'])
: $this->request['mail']['destination'];
}
if (isset($this->request['bounce']['bouncedRecipients'])) {
return collect($this->request['bounce']['bouncedRecipients'])
->pluck('emailAddress')
->implode(',');
}
if (isset($this->request['complaint']['complainedRecipients'])) {
return collect($this->request['complaint']['complainedRecipients'])
->pluck('emailAddress')
->implode(',');
}
return '';
}
/**
* Extract subject from SES webhook data
*/
private function extractSubject(): string
{
return $this->request['mail']['commonHeaders']['subject'] ??
$this->request['bounce']['bouncedRecipients'][0]['email'] ??
'';
}
/**
* Extract events from the webhook data
*/
private function extractEvents(): array
{
$event_type = $this->getCurrentEventType();
$message_id = $this->extractMessageId();
switch ($event_type) {
case 'delivery':
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Delivered',
'delivery_message' => $this->request['delivery']['smtpResponse'] ?? 'Successfully delivered',
'server' => $this->request['delivery']['processingTimeMillis'] ?? '',
'server_ip' => $this->request['delivery']['remoteMtaIp'] ?? '',
'date' => $this->request['delivery']['timestamp'] ?? now()->toISOString()
];
case 'bounce':
$bounce_data = $this->request['bounce'] ?? [];
return [
'bounce_id' => $bounce_data['bounceId'] ?? '',
'recipient' => $this->extractRecipients(),
'status' => 'Bounced',
'bounce_type' => $bounce_data['bounceType'] ?? '',
'bounce_sub_type' => $bounce_data['bounceSubType'] ?? '',
'date' => $bounce_data['timestamp'] ?? now()->toISOString()
];
case 'complaint':
$complaint_data = $this->request['complaint'] ?? [];
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Spam Complaint',
'complaint_type' => $complaint_data['complaintFeedbackType'] ?? '',
'date' => $complaint_data['timestamp'] ?? now()->toISOString()
];
case 'open':
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Opened',
'date' => now()->toISOString()
];
default:
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Unknown',
'date' => now()->toISOString()
];
}
}
/**
* Extract event data for the current webhook
*/
private function extractEventData(): array
{
$event_type = $this->getCurrentEventType();
$message_id = $this->extractMessageId();
switch ($event_type) {
case 'delivery':
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Delivered',
'delivery_message' => $this->request['delivery']['smtpResponse'] ?? 'Successfully delivered',
'server' => $this->request['delivery']['processingTimeMillis'] ?? '',
'server_ip' => $this->request['delivery']['remoteMtaIp'] ?? '',
'date' => $this->request['delivery']['timestamp'] ?? now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
case 'bounce':
$bounce_data = $this->request['bounce'] ?? [];
return [
'bounce_id' => $bounce_data['bounceId'] ?? '',
'recipient' => $this->extractRecipients(),
'status' => 'Bounced',
'bounce_type' => $bounce_data['bounceType'] ?? '',
'bounce_sub_type' => $bounce_data['bounceSubType'] ?? '',
'date' => $bounce_data['timestamp'] ?? now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
case 'complaint':
$complaint_data = $this->request['complaint'] ?? [];
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Spam Complaint',
'complaint_type' => $complaint_data['complaintFeedbackType'] ?? '',
'date' => $complaint_data['timestamp'] ?? now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
case 'open':
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Opened',
'date' => now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
default:
return [
'bounce_id' => '',
'recipient' => $this->extractRecipients(),
'status' => 'Unknown',
'date' => now()->toISOString(),
'event_type' => $event_type,
'timestamp' => now()->toISOString(),
'message_id' => $message_id
];
}
}
/**
* Handle job failure
*/
public function failed($exception = null)
{
if ($exception) {
nlog("SESWebhook:: " . $exception->getMessage());
}
config(['queue.failed.driver' => null]);
}
}