working mailgun inbound webhook

This commit is contained in:
paulwer 2024-03-18 08:04:54 +01:00
parent 2f4f547eed
commit c80e3bf921
7 changed files with 95 additions and 91 deletions

View File

@ -13,6 +13,7 @@ namespace App\Helpers\IngresMail\Transformer;
use App\Services\IngresEmail\IngresEmail; use App\Services\IngresEmail\IngresEmail;
use App\Utils\TempFile; use App\Utils\TempFile;
use Illuminate\Support\Carbon;
class MailgunInboundWebhookTransformer class MailgunInboundWebhookTransformer
{ {
@ -20,15 +21,25 @@ class MailgunInboundWebhookTransformer
{ {
$ingresEmail = new IngresEmail(); $ingresEmail = new IngresEmail();
$ingresEmail->from = $data["sender"]; $ingresEmail->from = $data["From"];
$ingresEmail->subject = $data["subject"]; $ingresEmail->to = $data["To"];
$ingresEmail->subject = $data["Subject"];
$ingresEmail->plain_message = $data["body-plain"]; $ingresEmail->plain_message = $data["body-plain"];
$ingresEmail->html_message = $data["body-html"]; $ingresEmail->html_message = $data["body-html"];
$ingresEmail->date = now(); // TODO $ingresEmail->date = Carbon::createFromTimestamp((int) $data["timestamp"]);
// parse documents as UploadedFile from webhook-data // parse documents as UploadedFile from webhook-data
foreach ($data["Attachments"] as $attachment) { foreach (json_decode($data["attachments"]) as $attachment) {
$ingresEmail->documents[] = TempFile::UploadedFileFromRaw($attachment["Content"], $attachment["Name"], $attachment["ContentType"]);
// prepare url with credentials before downloading :: https://github.com/mailgun/mailgun.js/issues/24
$url = $attachment->url;
$credentials = config('services.mailgun.domain') . ":" . config('services.mailgun.secret') . "@";
$url = str_replace("http://", "http://" . $credentials, $url);
$url = str_replace("https://", "https://" . $credentials, $url);
// download file and save to tmp dir
$ingresEmail->documents[] = TempFile::UploadedFileFromUrl($url, $attachment->name, $attachment->{"content-type"});
} }
return $ingresEmail; return $ingresEmail;

View File

@ -14,6 +14,7 @@ namespace App\Http\Controllers;
use App\Jobs\Mailgun\ProcessMailgunInboundWebhook; use App\Jobs\Mailgun\ProcessMailgunInboundWebhook;
use App\Jobs\Mailgun\ProcessMailgunWebhook; use App\Jobs\Mailgun\ProcessMailgunWebhook;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Log;
/** /**
* Class MailgunController. * Class MailgunController.
@ -112,10 +113,22 @@ class MailgunController extends BaseController
*/ */
public function inboundWebhook(Request $request) public function inboundWebhook(Request $request)
{ {
if ($request->header('X-API-SECURITY') && $request->header('X-API-SECURITY') == config('services.mailgun.token')) { $input = $request->all();
ProcessMailgunInboundWebhook::dispatch($request->all())->delay(10);
return response()->json(['message' => 'Success'], 200); if (!array_key_exists('attachments', $input) || count(json_decode($input['attachments'])) == 0) {
Log::info('Message ignored because of missing attachments. Please ensure contacting this api-endpoint with a store & notify operation instead of a forward operation');
return response()->json(['message' => 'Sucess. Soft Fail. Missing Attachments.'], 200);
}
if (\abs(\time() - (int) $request['timestamp']) > 150) {
Log::info('Message ignored because of request body is too old.');
return response()->json(['message' => 'Success. Soft Fail. Message too old.'], 200);
}
if (\hash_equals(\hash_hmac('sha256', $input['timestamp'] . $input['token'], config('services.mailgun.webhook_signing_key')), $input['signature'])) {
ProcessMailgunInboundWebhook::dispatch($input)->delay(10);
return response()->json(['message' => 'Success'], 201);
} }
return response()->json(['message' => 'Unauthorized'], 403); return response()->json(['message' => 'Unauthorized'], 403);

View File

@ -9,16 +9,17 @@
* @license https://www.elastic.co/licensing/elastic-license * @license https://www.elastic.co/licensing/elastic-license
*/ */
namespace App\Jobs\PostMark; namespace App\Jobs\Mailgun;
use App\Helpers\Mail\Webhook\Maigun\MailgunWebhookHandler; use App\Helpers\IngresMail\Transformer\MailgunInboundWebhookTransformer;
use App\Libraries\MultiDB; use App\Libraries\MultiDB;
use App\Models\SystemLog; use App\Services\IngresEmail\IngresEmailEngine;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Log;
class ProcessMailgunInboundWebhook implements ShouldQueue class ProcessMailgunInboundWebhook implements ShouldQueue
{ {
@ -26,18 +27,6 @@ class ProcessMailgunInboundWebhook implements ShouldQueue
public $tries = 1; public $tries = 1;
public $invitation;
private $entity;
private array $default_response = [
'recipients' => '',
'subject' => 'Message not found.',
'entity' => '',
'entity_id' => '',
'events' => [],
];
/** /**
* Create a new job instance. * Create a new job instance.
* *
@ -46,23 +35,6 @@ class ProcessMailgunInboundWebhook implements ShouldQueue
{ {
} }
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. * Execute the job.
* *
@ -71,20 +43,21 @@ class ProcessMailgunInboundWebhook implements ShouldQueue
*/ */
public function handle() public function handle()
{ {
// match companies if (!array_key_exists('To', $this->request) || !array_key_exists('attachments', $this->request) || !array_key_exists('timestamp', $this->request) || !array_key_exists('Subject', $this->request) || !(array_key_exists('body-html', $this->request) || array_key_exists('body-plain', $this->request)))
if (array_key_exists('ToFull', $this->request))
throw new \Exception('invalid body'); throw new \Exception('invalid body');
foreach ($this->request['ToFull'] as $toEmailEntry) { // match company
$toEmail = $toEmailEntry['Email']; $company = MultiDB::findAndSetDbByExpenseMailbox($this->request["To"]);
$company = MultiDB::findAndSetDbByExpenseMailbox($toEmail);
if (!$company) { if (!$company) {
nlog('unknown Expense Mailbox occured while handling an inbound email from postmark: ' . $toEmail); Log::info('unknown Expense Mailbox occured while handling an inbound email from mailgun: ' . $this->request["To"]);
continue; return;
} }
(new MailgunWebhookHandler())->process($this->request); // prepare
} $ingresMail = (new MailgunInboundWebhookTransformer())->transform($this->request);
Log::info(json_encode($ingresMail));
// perform
(new IngresEmailEngine($ingresMail))->handle();
} }
} }

View File

@ -12,25 +12,17 @@
namespace App\Services\IngresEmail; namespace App\Services\IngresEmail;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
/** /**
* EmailObject. * EmailObject.
*/ */
class IngresEmail class IngresEmail
{ {
/** @var array[string] $args */ public string $to;
public array $to = [];
public string $from; public string $from;
public array $reply_to = [];
/** @var array[string] $args */
public array $cc = [];
/** @var array[string] $args */
public array $bcc = [];
public ?string $subject = null; public ?string $subject = null;
public ?string $body = null; public ?string $body = null;
@ -41,7 +33,7 @@ class IngresEmail
/** @var array[\Illuminate\Http\UploadedFile] $documents */ /** @var array[\Illuminate\Http\UploadedFile] $documents */
public array $documents = []; public array $documents = [];
public ?\DateTimeImmutable $date = null; public ?Carbon $date = null;
function __constructor() function __constructor()
{ {

View File

@ -14,11 +14,9 @@ namespace App\Services\IngresEmail;
use App\Events\Expense\ExpenseWasCreated; use App\Events\Expense\ExpenseWasCreated;
use App\Factory\ExpenseFactory; use App\Factory\ExpenseFactory;
use App\Libraries\MultiDB; use App\Libraries\MultiDB;
use App\Models\Client;
use App\Models\Company; use App\Models\Company;
use App\Models\Vendor; use App\Models\Vendor;
use App\Models\VendorContact; use App\Models\VendorContact;
use App\Services\Email\EmailObject;
use App\Services\IngresEmail\IngresEmail; use App\Services\IngresEmail\IngresEmail;
use App\Utils\Ninja; use App\Utils\Ninja;
use App\Utils\TempFile; use App\Utils\TempFile;
@ -26,24 +24,18 @@ use App\Utils\Traits\GeneratesCounter;
use App\Utils\Traits\SavesDocuments; use App\Utils\Traits\SavesDocuments;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Cache; use Cache;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class IngresEmailEngine implements ShouldQueue class IngresEmailEngine
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, MakesHash; use SerializesModels, MakesHash;
use GeneratesCounter, SavesDocuments; use GeneratesCounter, SavesDocuments;
private IngresEmail $email;
private ?Company $company; private ?Company $company;
private ?bool $isUnknownRecipent = null; private ?bool $isUnknownRecipent = null;
private array $globalBlacklist = []; private array $globalBlacklist = [];
function __constructor(IngresEmail $email) public function __construct(private IngresEmail $email)
{ {
$this->email = $email;
} }
/** /**
* if there is not a company with an matching mailbox, we only do monitoring * if there is not a company with an matching mailbox, we only do monitoring
@ -53,14 +45,12 @@ class IngresEmailEngine implements ShouldQueue
{ {
if ($this->isInvalidOrBlocked()) if ($this->isInvalidOrBlocked())
return; return;
$this->isUnknownRecipent = true; $this->isUnknownRecipent = true;
// Expense Mailbox => will create an expense // Expense Mailbox => will create an expense
foreach ($this->email->to as $expense_mailbox) { $this->company = MultiDB::findAndSetDbByExpenseMailbox($this->email->to);
$this->company = MultiDB::findAndSetDbByExpenseMailbox($expense_mailbox); if ($this->company) {
if (!$this->company)
continue;
$this->isUnknownRecipent = false; $this->isUnknownRecipent = false;
$this->createExpense(); $this->createExpense();
} }
@ -112,14 +102,12 @@ class IngresEmailEngine implements ShouldQueue
} }
// wrong recipent occurs in more than 100 emails in the last 12 hours, so the processing is blocked // wrong recipent occurs in more than 100 emails in the last 12 hours, so the processing is blocked
foreach ($this->email->to as $recipent) { $mailCountUnknownRecipent = Cache::get('ingresEmailUnknownRecipent:' . $this->email->to, 0); // @turbo124 maybe use many to save resources in case of spam with multiple to addresses each time
$mailCountUnknownRecipent = Cache::get('ingresEmailUnknownRecipent:' . $recipent, 0); // @turbo124 maybe use many to save resources in case of spam with multiple to addresses each time
if ($mailCountUnknownRecipent >= 100) { if ($mailCountUnknownRecipent >= 100) {
nlog('[IngressMailEngine] E-Mail blocked, because anyone sended more than ' . $mailCountUnknownRecipent . ' emails to the wrong mailbox in the last 12 hours. Current sender was blocked as well: ' . $this->email->from); nlog('[IngressMailEngine] E-Mail blocked, because anyone sended more than ' . $mailCountUnknownRecipent . ' emails to the wrong mailbox in the last 12 hours. Current sender was blocked as well: ' . $this->email->from);
$this->blockSender(); $this->blockSender();
return true; return true;
} }
}
return false; return false;
} }
@ -141,10 +129,8 @@ class IngresEmailEngine implements ShouldQueue
Cache::add('ingresEmailSenderUnknownRecipent:' . $this->email->from, 0, now()->addHours(6)); Cache::add('ingresEmailSenderUnknownRecipent:' . $this->email->from, 0, now()->addHours(6));
Cache::increment('ingresEmailSenderUnknownRecipent:' . $this->email->from); // we save the sender, to may block him Cache::increment('ingresEmailSenderUnknownRecipent:' . $this->email->from); // we save the sender, to may block him
foreach ($this->email->to as $recipent) { Cache::add('ingresEmailUnknownRecipent:' . $this->email->to, 0, now()->addHours(12));
Cache::add('ingresEmailUnknownRecipent:' . $recipent, 0, now()->addHours(12)); Cache::increment('ingresEmailUnknownRecipent:' . $this->email->to); // we save the sender, to may block him
Cache::increment('ingresEmailUnknownRecipent:' . $recipent); // we save the sender, to may block him
}
} }
} }

View File

@ -99,4 +99,33 @@ class TempFile
// return UploadedFile object // return UploadedFile object
return $file; return $file;
} }
/* create a tmp file from a raw string: https://gist.github.com/waska14/8b3bcebfad1f86f7fcd3b82927576e38*/
public static function UploadedFileFromUrl(string $url, string|null $fileName = null, string|null $mimeType = null): UploadedFile
{
// Create temp file and get its absolute path
$tempFile = tmpfile();
$tempFilePath = stream_get_meta_data($tempFile)['uri'];
// Save file data in file
file_put_contents($tempFilePath, file_get_contents($url));
$tempFileObject = new File($tempFilePath);
$file = new UploadedFile(
$tempFileObject->getPathname(),
$fileName ?: $tempFileObject->getFilename(),
$mimeType ?: $tempFileObject->getMimeType(),
0,
true // Mark it as test, since the file isn't from real HTTP POST.
);
// Close this file after response is sent.
// Closing the file will cause to remove it from temp director!
app()->terminating(function () use ($tempFile) {
fclose($tempFile);
});
// return UploadedFile object
return $file;
}
} }

View File

@ -71,7 +71,6 @@ use App\Http\Controllers\CompanyLedgerController;
use App\Http\Controllers\PurchaseOrderController; use App\Http\Controllers\PurchaseOrderController;
use App\Http\Controllers\TaskSchedulerController; use App\Http\Controllers\TaskSchedulerController;
use App\Http\Controllers\CompanyGatewayController; use App\Http\Controllers\CompanyGatewayController;
use App\Http\Controllers\MailgunWebhookController;
use App\Http\Controllers\PaymentWebhookController; use App\Http\Controllers\PaymentWebhookController;
use App\Http\Controllers\RecurringQuoteController; use App\Http\Controllers\RecurringQuoteController;
use App\Http\Controllers\BankIntegrationController; use App\Http\Controllers\BankIntegrationController;
@ -426,6 +425,7 @@ 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_webhook', [PostMarkController::class, 'webhook'])->middleware('throttle:1000,1');
Route::post('api/v1/mailgun_webhook', [MailgunController::class, 'webhook'])->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('token_hash_router', [OneTimeTokenController::class, 'router'])->middleware('throttle:500,1');
Route::get('webcron', [WebCronController::class, 'index'])->middleware('throttle:100,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'); Route::post('api/v1/get_migration_account', [HostedMigrationController::class, 'getAccount'])->middleware('guest')->middleware('throttle:100,1');