diff --git a/app/Http/Controllers/MailgunController.php b/app/Http/Controllers/MailgunController.php index 11b7fb68cb48..1855934bae47 100644 --- a/app/Http/Controllers/MailgunController.php +++ b/app/Http/Controllers/MailgunController.php @@ -12,10 +12,11 @@ namespace App\Http\Controllers; use App\Jobs\Mailgun\ProcessMailgunInboundWebhook; +use App\Jobs\Mailgun\ProcessMailgunWebhook; use Illuminate\Http\Request; /** - * Class PostMarkController. + * Class MailgunController. */ class MailgunController extends BaseController { @@ -26,14 +27,59 @@ class MailgunController extends BaseController } /** - * Process Postmark Webhook. + * Process Mailgun Webhook. * * * @OA\Post( - * path="/api/v1/postmark_inbound_webhook", - * operationId="postmarkInboundWebhook", - * tags={"postmark"}, - * summary="Processing inbound webhooks from PostMark", + * path="/api/v1/mailgun_webhook", + * operationId="mailgunWebhook", + * tags={"mailgun"}, + * summary="Processing webhooks from Mailgun", + * description="Adds an credit to the system", + * @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Parameter(ref="#/components/parameters/include"), + * @OA\Response( + * response=200, + * description="Returns the saved credit object", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * @OA\JsonContent(ref="#/components/schemas/Credit"), + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + public function webhook(Request $request) + { + if ($request->header('X-API-SECURITY') && $request->header('X-API-SECURITY') == config('services.mailgun.token')) { + ProcessMailgunWebhook::dispatch($request->all())->delay(10); + + return response()->json(['message' => 'Success'], 200); + } + + return response()->json(['message' => 'Unauthorized'], 403); + } + + /** + * Process Mailgun Webhook. + * + * + * @OA\Post( + * path="/api/v1/mailgun_inbound_webhook", + * operationId="mailgunInboundWebhook", + * tags={"mailgun"}, + * summary="Processing inbound webhooks from Mailgun", * description="Adds an credit to the system", * @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"), * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), @@ -61,7 +107,7 @@ class MailgunController extends BaseController */ public function inboundWebhook(Request $request) { - if ($request->header('X-API-SECURITY') && $request->header('X-API-SECURITY') == config('services.postmark.token')) { + if ($request->header('X-API-SECURITY') && $request->header('X-API-SECURITY') == config('services.mailgun.token')) { ProcessMailgunInboundWebhook::dispatch($request->all())->delay(10); return response()->json(['message' => 'Success'], 200); diff --git a/app/Jobs/Mailgun/ProcessMailgunInboundWebhook.php b/app/Jobs/Mailgun/ProcessMailgunInboundWebhook.php index 069b11003b4c..5640d72c1392 100644 --- a/app/Jobs/Mailgun/ProcessMailgunInboundWebhook.php +++ b/app/Jobs/Mailgun/ProcessMailgunInboundWebhook.php @@ -9,7 +9,7 @@ * @license https://www.elastic.co/licensing/elastic-license */ -namespace App\Jobs\PostMark; +namespace App\Jobs\Mailgun; use App\DataMapper\Analytics\Mail\EmailBounce; use App\DataMapper\Analytics\Mail\EmailSpam; diff --git a/app/Jobs/Mailgun/ProcessMailgunWebhook.php b/app/Jobs/Mailgun/ProcessMailgunWebhook.php new file mode 100644 index 000000000000..f5bf05f69352 --- /dev/null +++ b/app/Jobs/Mailgun/ProcessMailgunWebhook.php @@ -0,0 +1,413 @@ + '', + 'subject' => 'Message not found.', + 'entity' => '', + 'entity_id' => '', + 'events' => [], + ]; + + /** + * 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) + ->whereJsonContains('log', ['MessageID' => $message_id]) + ->orderBy('id', 'desc') + ->first(); + + } + + private function updateSystemLog(SystemLog $system_log, array $data): void + { + $system_log->log = $data; + $system_log->save(); + } + + /** + * Execute the job. + * + * + * @return void + */ + public function handle() + { + MultiDB::findAndSetDbByCompanyKey($this->request['Tag']); + + $this->invitation = $this->discoverInvitation($this->request['MessageID']); + + if (!$this->invitation) { + return; + } + + if (array_key_exists('Details', $this->request)) { + $this->invitation->email_error = $this->request['Details']; + } + + switch ($this->request['RecordType']) { + case 'delivered': + return $this->processDelivery(); + case 'permanent_fail': + case 'temporary_fail': + return $this->processBounce(); + case 'complained': + return $this->processSpamComplaint(); + case 'opened': + return $this->processOpen(); + default: + # code... + break; + } + } + + // { + // "Metadata": { + // "example": "value", + // "example_2": "value" + // }, + // "RecordType": "Open", + // "FirstOpen": true, + // "Client": { + // "Name": "Chrome 35.0.1916.153", + // "Company": "Google", + // "Family": "Chrome" + // }, + // "OS": { + // "Name": "OS X 10.7 Lion", + // "Company": "Apple Computer, Inc.", + // "Family": "OS X 10" + // }, + // "Platform": "WebMail", + // "UserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.153 Safari/537.36", + // "ReadSeconds": 5, + // "Geo": { + // "CountryISOCode": "RS", + // "Country": "Serbia", + // "RegionISOCode": "VO", + // "Region": "Autonomna Pokrajina Vojvodina", + // "City": "Novi Sad", + // "Zip": "21000", + // "Coords": "45.2517,19.8369", + // "IP": "188.2.95.4" + // }, + // "MessageID": "00000000-0000-0000-0000-000000000000", + // "MessageStream": "outbound", + // "ReceivedAt": "2022-02-06T06:37:48Z", + // "Tag": "welcome-email", + // "Recipient": "john@example.com" + // } + + private function processOpen() + { + $this->invitation->opened_date = now(); + $this->invitation->save(); + + $data = array_merge($this->request, ['history' => $this->fetchMessage()]); + + $sl = $this->getSystemLog($this->request['MessageID']); + + 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(); + } + + // { + // "RecordType": "Delivery", + // "ServerID": 23, + // "MessageStream": "outbound", + // "MessageID": "00000000-0000-0000-0000-000000000000", + // "Recipient": "john@example.com", + // "Tag": "welcome-email", + // "DeliveredAt": "2021-02-21T16:34:52Z", + // "Details": "Test delivery webhook details", + // "Metadata": { + // "example": "value", + // "example_2": "value" + // } + // } + private function processDelivery() + { + $this->invitation->email_status = 'delivered'; + $this->invitation->save(); + + $data = array_merge($this->request, ['history' => $this->fetchMessage()]); + + $sl = $this->getSystemLog($this->request['MessageID']); + + 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(); + } + + // { + // "Metadata": { + // "example": "value", + // "example_2": "value" + // }, + // "RecordType": "Bounce", + // "ID": 42, + // "Type": "HardBounce", + // "TypeCode": 1, + // "Name": "Hard bounce", + // "Tag": "Test", + // "MessageID": "00000000-0000-0000-0000-000000000000", + // "ServerID": 1234, + // "MessageStream": "outbound", + // "Description": "The server was unable to deliver your message (ex: unknown user, mailbox not found).", + // "Details": "Test bounce details", + // "Email": "john@example.com", + // "From": "sender@example.com", + // "BouncedAt": "2021-02-21T16:34:52Z", + // "DumpAvailable": true, + // "Inactive": true, + // "CanActivate": true, + // "Subject": "Test subject", + // "Content": "Test content" + // } + + private function processBounce() + { + $this->invitation->email_status = 'bounced'; + $this->invitation->save(); + + $bounce = new EmailBounce( + $this->request['Tag'], + $this->request['From'], + $this->request['MessageID'] + ); + + LightLogs::create($bounce)->send(); + + $data = array_merge($this->request, ['history' => $this->fetchMessage()]); + + $sl = $this->getSystemLog($this->request['MessageID']); + + 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(); + + // if(config('ninja.notification.slack')) + // $this->invitation->company->notification(new EmailBounceNotification($this->invitation->company->account))->ninja(); + } + + // { + // "Metadata": { + // "example": "value", + // "example_2": "value" + // }, + // "RecordType": "SpamComplaint", + // "ID": 42, + // "Type": "SpamComplaint", + // "TypeCode": 100001, + // "Name": "Spam complaint", + // "Tag": "Test", + // "MessageID": "00000000-0000-0000-0000-000000000000", + // "ServerID": 1234, + // "MessageStream": "outbound", + // "Description": "The subscriber explicitly marked this message as spam.", + // "Details": "Test spam complaint details", + // "Email": "john@example.com", + // "From": "sender@example.com", + // "BouncedAt": "2021-02-21T16:34:52Z", + // "DumpAvailable": true, + // "Inactive": true, + // "CanActivate": false, + // "Subject": "Test subject", + // "Content": "Test content" + // } + private function processSpamComplaint() + { + $this->invitation->email_status = 'spam'; + $this->invitation->save(); + + $spam = new EmailSpam( + $this->request['Tag'], + $this->request['From'], + $this->request['MessageID'] + ); + + LightLogs::create($spam)->send(); + + $data = array_merge($this->request, ['history' => $this->fetchMessage()]); + + $sl = $this->getSystemLog($this->request['MessageID']); + + 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(); + + if (config('ninja.notification.slack')) { + $this->invitation->company->notification(new EmailSpamNotification($this->invitation->company->account))->ninja(); + } + } + + 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; + } + } + + public function getRawMessage(string $message_id) + { + + $postmark = new PostmarkClient(config('services.postmark.token')); + $messageDetail = $postmark->getOutboundMessageDetails($message_id); + return $messageDetail; + + } + + + public function getBounceId(string $message_id): ?int + { + + $messageDetail = $this->getRawMessage($message_id); + + + $event = collect($messageDetail->messageevents)->first(function ($event) { + + return $event?->Details?->BounceID ?? false; + + }); + + return $event?->Details?->BounceID ?? null; + + } + + private function fetchMessage(): array + { + if (strlen($this->request['MessageID']) < 1) { + return $this->default_response; + } + + try { + + $postmark = new PostmarkClient(config('services.postmark.token')); + $messageDetail = $postmark->getOutboundMessageDetails($this->request['MessageID']); + + $recipients = collect($messageDetail['recipients'])->flatten()->implode(','); + $subject = $messageDetail->subject ?? ''; + + $events = collect($messageDetail->messageevents)->map(function ($event) { + + return [ + 'bounce_id' => $event?->Details?->BounceID ?? '', + 'recipient' => $event->Recipient ?? '', + 'status' => $event->Type ?? '', + 'delivery_message' => $event->Details->DeliveryMessage ?? $event->Details->Summary ?? '', + 'server' => $event->Details->DestinationServer ?? '', + 'server_ip' => $event->Details->DestinationIP ?? '', + 'date' => \Carbon\Carbon::parse($event->ReceivedAt)->format('Y-m-d H:i:s') ?? '', + ]; + + })->toArray(); + + return [ + 'recipients' => $recipients, + 'subject' => $subject, + 'entity' => $this->entity ?? '', + 'entity_id' => $this->invitation->{$this->entity}->hashed_id ?? '', + 'events' => $events, + ]; + + } catch (\Exception $e) { + + return $this->default_response; + + } + } +} diff --git a/routes/api.php b/routes/api.php index 69b8530bf312..ae6fbdd28dc8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -46,6 +46,7 @@ use App\Http\Controllers\InAppPurchase\AppleController; use App\Http\Controllers\InvoiceController; use App\Http\Controllers\LicenseController; use App\Http\Controllers\LogoutController; +use App\Http\Controllers\MailgunController; use App\Http\Controllers\MigrationController; use App\Http\Controllers\OneTimeTokenController; use App\Http\Controllers\PaymentController; @@ -416,7 +417,9 @@ Route::match(['get', 'post'], 'payment_notification_webhook/{company_key}/{compa Route::post('api/v1/postmark_webhook', [PostMarkController::class, 'webhook'])->middleware('throttle:1000,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'); + Route::get('token_hash_router', [OneTimeTokenController::class, 'router'])->middleware('throttle:500,1'); Route::get('webcron', [WebCronController::class, 'index'])->middleware('throttle:100,1'); Route::post('api/v1/get_migration_account', [HostedMigrationController::class, 'getAccount'])->middleware('guest')->middleware('throttle:100,1');