mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-03-03 02:57:01 +00:00
Fixes for tests
This commit is contained in:
258
app/Http/Controllers/SNSController.php
Normal file
258
app/Http/Controllers/SNSController.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\SES\SESWebhook;
|
||||
use App\Jobs\PostMark\ProcessPostmarkWebhook;
|
||||
use App\Libraries\MultiDB;
|
||||
use App\Services\InboundMail\InboundMail;
|
||||
use App\Services\InboundMail\InboundMailEngine;
|
||||
use App\Utils\TempFile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Class SNSController.
|
||||
*
|
||||
* Handles Amazon SNS webhook notifications that contain SES email event data.
|
||||
* SNS acts as an intermediary between SES and your application.
|
||||
*/
|
||||
class SNSController extends BaseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SNS webhook notifications
|
||||
*
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function webhook(Request $request)
|
||||
{
|
||||
try {
|
||||
// Get the raw request body for SNS signature verification
|
||||
$payload = $request->getContent();
|
||||
$headers = $request->headers->all();
|
||||
|
||||
Log::info('SNS Webhook received', [
|
||||
'headers' => $headers,
|
||||
'payload_size' => strlen($payload)
|
||||
]);
|
||||
|
||||
// Parse the SNS payload
|
||||
$snsData = json_decode($payload, true);
|
||||
|
||||
if (!$snsData) {
|
||||
Log::error('SNS Webhook: Invalid JSON payload');
|
||||
return response()->json(['error' => 'Invalid JSON payload'], 400);
|
||||
}
|
||||
|
||||
// Get SNS message type from headers (AWS SNS specific)
|
||||
$snsMessageType = $headers['x-amz-sns-message-type'][0] ?? null;
|
||||
|
||||
if (!$snsMessageType) {
|
||||
Log::error('SNS Webhook: Missing x-amz-sns-message-type header');
|
||||
return response()->json(['error' => 'Missing SNS message type'], 400);
|
||||
}
|
||||
|
||||
// Handle SNS subscription confirmation
|
||||
if ($snsMessageType === 'SubscriptionConfirmation') {
|
||||
return $this->handleSubscriptionConfirmation($snsData);
|
||||
}
|
||||
|
||||
// Handle SNS notification (contains SES data)
|
||||
if ($snsMessageType === 'Notification') {
|
||||
return $this->handleSESNotification($snsData);
|
||||
}
|
||||
|
||||
// Handle unsubscribe confirmation
|
||||
if ($snsMessageType === 'UnsubscribeConfirmation') {
|
||||
Log::info('SNS Unsubscribe confirmation received', ['topic_arn' => $snsData['TopicArn'] ?? 'unknown']);
|
||||
return response()->json(['status' => 'unsubscribe_confirmed']);
|
||||
}
|
||||
|
||||
Log::warning('SNS Webhook: Unknown message type', ['type' => $snsMessageType]);
|
||||
return response()->json(['error' => 'Unknown message type'], 400);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SNS Webhook: Error processing request', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return response()->json(['error' => 'Internal server error'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SNS subscription confirmation
|
||||
*
|
||||
* @param array $snsData
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
private function handleSubscriptionConfirmation(array $snsData)
|
||||
{
|
||||
$subscribeUrl = $snsData['SubscribeURL'] ?? null;
|
||||
|
||||
if (!$subscribeUrl) {
|
||||
Log::error('SNS Subscription confirmation: Missing SubscribeURL');
|
||||
return response()->json(['error' => 'Missing SubscribeURL'], 400);
|
||||
}
|
||||
|
||||
Log::info('SNS Subscription confirmation received', [
|
||||
'topic_arn' => $snsData['TopicArn'] ?? 'unknown',
|
||||
'subscribe_url' => $subscribeUrl
|
||||
]);
|
||||
|
||||
// You can optionally make an HTTP request to confirm the subscription
|
||||
// This is required by AWS to complete the SNS subscription setup
|
||||
try {
|
||||
$response = file_get_contents($subscribeUrl);
|
||||
Log::info('SNS Subscription confirmed', ['response' => $response]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SNS Subscription confirmation failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'subscription_confirmed']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle SES notification from SNS
|
||||
*
|
||||
* @param array $snsData
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
private function handleSESNotification(array $snsData)
|
||||
{
|
||||
$message = $snsData['Message'] ?? null;
|
||||
|
||||
if (!$message) {
|
||||
Log::error('SNS Notification: Missing Message content');
|
||||
return response()->json(['error' => 'Missing Message content'], 400);
|
||||
}
|
||||
|
||||
// Parse the SES message (it's JSON encoded as a string)
|
||||
$sesData = json_decode($message, true);
|
||||
|
||||
if (!$sesData) {
|
||||
Log::error('SNS Notification: Invalid SES message format');
|
||||
return response()->json(['error' => 'Invalid SES message format'], 400);
|
||||
}
|
||||
|
||||
Log::info('SNS Notification: Processing SES data', [
|
||||
'notification_type' => $sesData['notificationType'] ?? $sesData['eventType'] ?? 'unknown',
|
||||
'message_id' => $sesData['mail']['messageId'] ?? 'unknown'
|
||||
]);
|
||||
|
||||
// Extract company key from SES data
|
||||
$companyKey = $this->extractCompanyKeyFromSES($sesData);
|
||||
|
||||
if (!$companyKey) {
|
||||
Log::warning('SNS Notification: No company key found in SES data', [
|
||||
'ses_data' => $sesData
|
||||
]);
|
||||
return response()->json(['error' => 'No company key found'], 400);
|
||||
}
|
||||
|
||||
// Dispatch the SES webhook job for processing
|
||||
try {
|
||||
SESWebhook::dispatch($sesData);
|
||||
|
||||
Log::info('SNS Notification: SES webhook job dispatched successfully', [
|
||||
'company_key' => $companyKey,
|
||||
'message_id' => $sesData['mail']['messageId'] ?? 'unknown'
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'webhook_processed']);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SNS Notification: Failed to dispatch SES webhook job', [
|
||||
'error' => $e->getMessage(),
|
||||
'company_key' => $companyKey
|
||||
]);
|
||||
|
||||
return response()->json(['error' => 'Failed to process webhook'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract company key from SES data
|
||||
*
|
||||
* @param array $sesData
|
||||
* @return string|null
|
||||
*/
|
||||
private function extractCompanyKeyFromSES(array $sesData): ?string
|
||||
{
|
||||
// Check various possible locations for company key in SES data
|
||||
|
||||
// Check mail tags
|
||||
if (isset($sesData['mail']['tags']['company_key'])) {
|
||||
Log::info('SNS: Found company key in mail tags', ['value' => $sesData['mail']['tags']['company_key']]);
|
||||
return $sesData['mail']['tags']['company_key'];
|
||||
}
|
||||
|
||||
// Check custom headers - specifically X-Tag which contains the company key
|
||||
if (isset($sesData['mail']['headers'])) {
|
||||
Log::info('SNS: Checking mail headers for X-Tag', [
|
||||
'headers_count' => count($sesData['mail']['headers']),
|
||||
'headers' => $sesData['mail']['headers']
|
||||
]);
|
||||
|
||||
foreach ($sesData['mail']['headers'] as $header) {
|
||||
if (isset($header['name']) && $header['name'] === 'X-Tag' && isset($header['value'])) {
|
||||
Log::info('SNS: Found X-Tag header', ['value' => $header['value']]);
|
||||
return $header['value'];
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('SNS: X-Tag header not found in mail headers');
|
||||
}
|
||||
|
||||
// Check if company key is in the main SES data
|
||||
if (isset($sesData['company_key'])) {
|
||||
Log::info('SNS: Found company key in main SES data', ['value' => $sesData['company_key']]);
|
||||
return $sesData['company_key'];
|
||||
}
|
||||
|
||||
// Check bounce data
|
||||
if (isset($sesData['bounce']) && isset($sesData['bounce']['tags']['company_key'])) {
|
||||
Log::info('SNS: Found company key in bounce tags', ['value' => $sesData['bounce']['tags']['company_key']]);
|
||||
return $sesData['bounce']['tags']['company_key'];
|
||||
}
|
||||
|
||||
// Check complaint data
|
||||
if (isset($sesData['complaint']) && isset($sesData['complaint']['tags']['company_key'])) {
|
||||
Log::info('SNS: Found company key in complaint tags', ['value' => $sesData['complaint']['tags']['company_key']]);
|
||||
return $sesData['complaint']['tags']['company_key'];
|
||||
}
|
||||
|
||||
// Check delivery data
|
||||
if (isset($sesData['delivery']) && isset($sesData['delivery']['tags']['company_key'])) {
|
||||
Log::info('SNS: Found company key in delivery tags', ['value' => $sesData['delivery']['tags']['company_key']]);
|
||||
return $sesData['delivery']['tags']['company_key'];
|
||||
}
|
||||
|
||||
Log::warning('SNS: No company key found in any location', [
|
||||
'mail_headers_exists' => isset($sesData['mail']['headers']),
|
||||
'mail_common_headers_exists' => isset($sesData['mail']['commonHeaders']),
|
||||
'bounce_exists' => isset($sesData['bounce']),
|
||||
'complaint_exists' => isset($sesData['complaint']),
|
||||
'delivery_exists' => isset($sesData['delivery'])
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
661
app/Jobs/SES/SESWebhook.php
Normal file
661
app/Jobs/SES/SESWebhook.php
Normal file
@@ -0,0 +1,661 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ class MailSentListener
|
||||
{
|
||||
|
||||
try {
|
||||
|
||||
$message_id = $event->sent->getMessageId();
|
||||
|
||||
$message = MessageConverter::toEmail($event->sent->getOriginalMessage()); //@phpstan-ignore-line
|
||||
@@ -53,6 +54,10 @@ class MailSentListener
|
||||
return;
|
||||
}
|
||||
|
||||
if($message->getHeaders()->get('x-message-id')) {
|
||||
$message_id = $message->getHeaders()->get('x-message-id')->getValue();
|
||||
}
|
||||
|
||||
$invitation_key = $message->getHeaders()->get('x-invitation')->getValue();
|
||||
|
||||
if ($message_id && $invitation_key) {
|
||||
|
||||
@@ -23,6 +23,7 @@ class DeletePayment
|
||||
{
|
||||
private float $_paid_to_date_deleted = 0;
|
||||
|
||||
private float $total_payment_amount = 0;
|
||||
/**
|
||||
* @param Payment $payment
|
||||
* @return void
|
||||
@@ -91,6 +92,8 @@ class DeletePayment
|
||||
|
||||
$invoice_ids = $this->payment->invoices()->pluck('invoices.id')->toArray();
|
||||
|
||||
$this->total_payment_amount = $this->payment->amount + ($this->payment->paymentables->where('paymentable_type', 'App\Models\Credit')->sum('amount') - $this->payment->paymentables->where('paymentable_type', 'App\Models\Credit')->sum('refunded'));
|
||||
|
||||
$this->payment->invoices()->each(function ($paymentable_invoice) {
|
||||
$net_deletable = $paymentable_invoice->pivot->amount - $paymentable_invoice->pivot->refunded;
|
||||
|
||||
@@ -144,7 +147,8 @@ class DeletePayment
|
||||
$this->payment
|
||||
->client
|
||||
->service()
|
||||
->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 - ($this->payment->amount - $this->payment->applied))) // if negative, set to 0, the paid to date will be reduced further down.
|
||||
->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 )) // if negative, set to 0, the paid to date will be reduced further down.
|
||||
// ->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 - ($this->payment->amount - $this->payment->applied))) // if negative, set to 0, the paid to date will be reduced further down.
|
||||
->save();
|
||||
|
||||
if (abs(floatval($paymentable_invoice->balance) - floatval($paymentable_invoice->amount)) < 0.005) {
|
||||
@@ -172,8 +176,14 @@ class DeletePayment
|
||||
//sometimes the payment is NOT created properly, this catches the payment and prevents the paid to date reducing inappropriately.
|
||||
if ($this->update_client_paid_to_date) {
|
||||
|
||||
// $reduced_paid_to_date = $this->payment->amount < 0 ? $this->payment->amount * -1 : min(0, ($this->payment->amount - $this->payment->refunded - $this->_paid_to_date_deleted) * -1);
|
||||
$reduced_paid_to_date = $this->payment->amount < 0 ? $this->payment->amount * -1 : min(0, ($this->payment->amount - $this->payment->refunded - $this->_paid_to_date_deleted) * -1);
|
||||
|
||||
/** handle the edge case where a partial credit + unapplied payment is deleted */
|
||||
if(floatval($this->total_payment_amount) != floatval($this->_paid_to_date_deleted)) {
|
||||
$reduced_paid_to_date = ($this->total_payment_amount - $this->_paid_to_date_deleted) * -1;
|
||||
}
|
||||
|
||||
nlog("reduced paid to date: {$reduced_paid_to_date}");
|
||||
if($reduced_paid_to_date != 0) {
|
||||
$this->payment
|
||||
|
||||
@@ -73,8 +73,8 @@ return [
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'key' => env('SES_AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('SES_AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('SES_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
|
||||
*/
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\SNSController;
|
||||
use App\Http\Controllers\BaseController;
|
||||
use App\Http\Controllers\PingController;
|
||||
use App\Http\Controllers\SmtpController;
|
||||
@@ -413,7 +414,7 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
|
||||
Route::post('settings/enable_two_factor', [TwoFactorController::class, 'enableTwoFactor']);
|
||||
Route::post('settings/disable_two_factor', [TwoFactorController::class, 'disableTwoFactor']);
|
||||
|
||||
Route::post('verify', [TwilioController::class, 'generate'])->name('verify.generate')->middleware('throttle:3,1');
|
||||
Route::post('verify', [TwilioController::class, 'generate'])->name('verify.generate')->middleware('throttle:1,1');
|
||||
Route::post('verify/confirm', [TwilioController::class, 'confirm'])->name('verify.confirm');
|
||||
|
||||
Route::resource('vendors', VendorController::class); // name = (vendors. index / create / show / update / destroy / edit
|
||||
@@ -478,6 +479,7 @@ Route::match(['get', 'post'], 'payment_notification_webhook/{company_key}/{compa
|
||||
->name('payment_notification_webhook');
|
||||
|
||||
Route::post('api/v1/postmark_webhook', [PostMarkController::class, 'webhook'])->middleware('throttle:5000,1');
|
||||
Route::post('api/v1/sns_webhook', [SNSController::class, 'webhook'])->middleware('throttle:5000,1');
|
||||
Route::post('api/v1/postmark_inbound_webhook', [PostMarkController::class, 'inboundWebhook'])->middleware('throttle:1000,1');
|
||||
Route::post('api/v1/mailgun_webhook', [MailgunController::class, 'webhook'])->middleware('throttle:1000,1');
|
||||
Route::post('api/v1/mailgun_inbound_webhook', [MailgunController::class, 'inboundWebhook'])->middleware('throttle:1000,1');
|
||||
|
||||
@@ -189,14 +189,25 @@ class UnappliedPaymentDeleteTest extends TestCase
|
||||
$payment_hashed_id = $arr['data']['id'];
|
||||
$payment = Payment::find($this->decodePrimaryKey($payment_hashed_id));
|
||||
|
||||
$payment->invoices()->each(function ($i){
|
||||
|
||||
$this->assertEquals(20, $i->pivot->amount);
|
||||
$this->assertEquals(0, $i->pivot->refunded);
|
||||
});
|
||||
|
||||
$this->assertEquals(30, $payment->amount);
|
||||
$this->assertEquals(20, $payment->applied);
|
||||
|
||||
$this->assertEquals(30, $client->fresh()->paid_to_date);
|
||||
|
||||
$payment->service()->deletePayment();
|
||||
|
||||
$payment->fresh();
|
||||
$invoice->fresh();
|
||||
|
||||
$this->assertEquals(20, $invoice->fresh()->balance);
|
||||
$this->assertEquals(0, $invoice->fresh()->paid_to_date);
|
||||
|
||||
$this->assertEquals(0, $client->fresh()->paid_to_date);
|
||||
$this->assertEquals(20, $client->fresh()->balance);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user