diff --git a/app/Http/Controllers/BrevoController.php b/app/Http/Controllers/BrevoController.php index 7ea09493e98a..81c02bdcd376 100644 --- a/app/Http/Controllers/BrevoController.php +++ b/app/Http/Controllers/BrevoController.php @@ -11,6 +11,7 @@ namespace App\Http\Controllers; +use App\Jobs\Brevo\ProcessBrevoInboundWebhook; use App\Jobs\Brevo\ProcessBrevoWebhook; use Illuminate\Http\Request; @@ -24,14 +25,14 @@ class BrevoController extends BaseController } /** - * Process Postmark Webhook. + * Process Brevo Webhook. * * * @OA\Post( - * path="/api/v1/postmark_webhook", - * operationId="postmarkWebhook", - * tags={"postmark"}, - * summary="Processing webhooks from PostMark", + * path="/api/v1/brevo_webhook", + * operationId="brevoWebhook", + * tags={"brevo"}, + * summary="Processing webhooks from Brevo", * description="Adds an credit to the system", * @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"), * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), @@ -59,12 +60,150 @@ class BrevoController extends BaseController */ public function webhook(Request $request) { - if ($request->has('token') && $request->get('token') == config('services.brevo.key')) { - ProcessBrevoWebhook::dispatch($request->all())->delay(10); + if ($request->has('token') && $request->get('token') == config('services.brevo.secret')) { + ProcessBrevoWebhook::dispatch($request->all())->delay(rand(2, 10)); return response()->json(['message' => 'Success'], 200); } return response()->json(['message' => 'Unauthorized'], 403); } + + + /** + * Process Brevo Inbound Webhook. + * + * IMPORTANT NOTICE: brevo strips old sended emails, therefore only current attachements are present + * + * IMPORTANT NOTICE: brevo saves the message and attachemnts for later retrieval, therefore we can process it within a async job for performance reasons + * + * @OA\Post( + * path="/api/v1/brevo_inbound_webhook", + * operationId="brevoInboundWebhook", + * tags={"brevo"}, + * summary="Processing inbound webhooks from Brevo", + * 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"), + * ), + * ) + * + * array ( + * 'items' => + * array ( + * 0 => + * array ( + * 'Uuid' => + * array ( + * 0 => 'd9f48d52-a344-42a4-9056-9733488d9fa3', + * ), + * 'Recipients' => + * array ( + * 0 => 'test@test.de', + * ), + * 'MessageId' => '', + * 'InReplyTo' => NULL, + * 'From' => + * array ( + * 'Name' => 'Max Mustermann', + * 'Address' => 'max@mustermann.de', + * ), + * 'To' => + * array ( + * 0 => + * array ( + * 'Name' => NULL, + * 'Address' => 'test@test.de', + * ), + * ), + * 'Cc' => + * array ( + * ), + * 'Bcc' => + * array ( + * ), + * 'ReplyTo' => NULL, + * 'SentAtDate' => 'Sat, 23 Mar 2024 18:18:20 +0100', + * 'Subject' => 'TEST', + * 'Attachments' => + * array ( + * 0 => + * array ( + * 'Name' => 'flag--sv-1x1.svg', + * 'ContentType' => 'image/svg+xml', + * 'ContentLength' => 79957, + * 'ContentID' => 'f_lu4ct6s20', + * 'DownloadToken' => 'eyJmb2xkZXIiOiIyMDI0MDMyMzE3MTgzNi45OS43OTgwMDM4MDQiLCJmaWxlbmFtZSI6ImZsYWctLXN2LTF4MS5zdmcifQ', + * ), + * ), + * 'Headers' => + * array ( + * 'Received' => 'by mail-ed1-f51.google.com with SMTP id 4fb4d7f45d1cf-56b0af675deso3877288a12.1 for ; Sat, 23 Mar 2024 10:18:36 -0700 (PDT)', + * 'DKIM-Signature' => 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=mustermann.de; s=google; t=1711214316; x=1711819116; darn=test.de; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject :date:message-id:reply-to; bh=eBSl5M0zvmTd+dFXGXMMSWrQ4nCvUdyVx+1Xpl+YuX8=; b=ackw3d+qTvZk4JKxomvH626MvfwmH23mikOUc2hWwYiO6unmQgPs2w5spnkmD9aCZ9 G+3nPSYKntugOmqWstZH3z4B063U4Y6j5hTc19WtCyyb9UR+XD+C6L10yc6ez8QUhlZT uAGqDoJ+E8+dBxiMul2pow19lC88t3QxRXU+i8zScniV7SFkwzziCEODaB61yI0DXsZB bUkx5Gx6cztKaNVF2QgguF2nQnJFUnD2nabVFsihyJ5r6y61rkSM/YTfMJuES772lnhv IeF+vwiFNEPKafrchce6YJcvo5Vd5lYFK4LtHyCy3mwJpX2QY+WnWAfferZ2YfgEL0Sf K3Pw==', + * 'X-Google-DKIM-Signature' => 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1711214316; x=1711819116; h=to:subject:message-id:date:from:mime-version:x-gm-message-state :from:to:cc:subject:date:message-id:reply-to; bh=eBSl5M0zvmTd+dFXGXMMSWrQ4nCvUdyVx+1Xpl+YuX8=; b=fg4tXZnstRBexYlC6MD7C7is0kQj+xY66cSJ78tSa7PtSFQzY0zajDMsepMCGiiWmN /Pc/tRtk53pru/OtfzRT9pbM6mhM1arIt+QaQBQGU5xZVV5JXfPmdnPzXqAbQztyeHrk UcEkz+qDN3JNoidw2dJhhdt5MxdKssR572NwtBrn/rN7f1o/ThWzEz+P0o06GVBpxVYP wM0EkvcJj2SUOcn36kmp1ccbMUwYCU2h1JmniEFY8RTqu2il13iXoBvG4YPxe0c0hJ6z zw1N5rONeQM113N1rpbQzS1QLSngczuOhN24M3TOwrHJIec/BxrOW6KWl/uPUqiZAf65 f0tg==', + * 'X-Gm-Message-State' => 'AOJu0YzKhR1HY1oUXoq++LLpl6UOz1S60NfPxuPXBLcP+6aACYle8rqQ fYHe2rQYTpg4KWiOswu858STOW8qmiewXD6gH/LbmEFs7sknRyDPNr/+L0cv828A3o+SOvXu3uP SY6H1aNSwIpqTRhJ+nNjTuSUpuSoABd9fYXFwPuivV0DtBhoVmpE=', + * 'X-Google-Smtp-Source' => 'AGHT+IHdA9ZhW0dQxgOYx2OXBGmu4pzSR/zwJ0vcPNXFSqttKCPS2oTw1a9b2mMdhyUeoRAwP5TmhHlAtqUUrOPwkgg=', + * 'X-Received' => 'by 2002:a50:d74c:0:b0:567:3c07:8bbc with SMTP id i12-20020a50d74c000000b005673c078bbcmr2126401edj.21.1711214316135; Sat, 23 Mar 2024 10:18:36 -0700 (PDT)', + * 'MIME-Version' => '1.0', + * 'From' => 'Max Mustermann ', + * 'Date' => 'Sat, 23 Mar 2024 18:18:20 +0100', + * 'Message-ID' => '', + * 'Subject' => 'TEST', + * 'To' => 'test@test.de', + * 'Content-Type' => 'multipart/mixed', + * ), + * 'SpamScore' => 2.8, + * 'ExtractedMarkdownMessage' => 'TEST', + * 'ExtractedMarkdownSignature' => NULL, + * 'RawHtmlBody' => '
TEST
', + * 'RawTextBody' => 'TEST', + * 'EMLDownloadToken' => 'eyJmb2xkZXIiOiIyMDI0MDMyMzE3MTgzNi45OS43OTgwMDM4MDQiLCJmaWxlbmFtZSI6InNtdHAuZW1sIn0', + * ), + * ), + * ) + */ + public function inboundWebhook(Request $request) + { + $input = $request->all(); + + if (!($request->has('token') && $request->get('token') == config('ninja.inbound_mailbox.inbound_webhook_token'))) + return response()->json(['message' => 'Unauthorized'], 403); + + if (!array_key_exists('items', $input)) { + nlog('Failed: Message could not be parsed, because required parameters are missing.'); + return response()->json(['message' => 'Failed. Invalid Parameters.'], 400); + } + + foreach ($input["items"] as $item) { + + if (!array_key_exists('Recipients', $item) || !array_key_exists('MessageId', $item)) { + nlog('Failed: Message could not be parsed, because required parameters are missing. At least one item was invalid.'); + return response()->json(['message' => 'Failed. Invalid Parameters. At least one item was invalid.'], 400); + } + + ProcessBrevoInboundWebhook::dispatch($item)->delay(rand(2, 10)); + + } + + return response()->json(['message' => 'Success'], 201); + } } diff --git a/app/Http/Controllers/ExpenseController.php b/app/Http/Controllers/ExpenseController.php index eaeee304f7db..5a702c6842e8 100644 --- a/app/Http/Controllers/ExpenseController.php +++ b/app/Http/Controllers/ExpenseController.php @@ -497,7 +497,7 @@ class ExpenseController extends BaseController $expenses = Expense::withTrashed()->find($request->ids); - if($request->action == 'bulk_categorize' && $user->can('edit', $expenses->first())) { + if ($request->action == 'bulk_categorize' && $user->can('edit', $expenses->first())) { $this->expense_repo->categorize($expenses, $request->category_id); $expenses = collect([]); } @@ -573,7 +573,7 @@ class ExpenseController extends BaseController */ public function upload(UploadExpenseRequest $request, Expense $expense) { - if (! $this->checkFeature(Account::FEATURE_DOCUMENTS)) { + if (!$this->checkFeature(Account::FEATURE_DOCUMENTS)) { return $this->featureFailure(); } @@ -584,15 +584,60 @@ class ExpenseController extends BaseController return $this->itemResponse($expense->fresh()); } + /** + * @OA\Post( + * path="/api/v1/expenses/edocument", + * operationId="edocumentExpense", + * tags={"expenses"}, + * summary="Uploads an electronic document to a expense", + * description="Handles the uploading of an electronic document to a expense", + * @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Parameter(ref="#/components/parameters/include"), + * @OA\RequestBody( + * description="User credentials", + * required=true, + * @OA\MediaType( + * mediaType="multipart/form-data", + * @OA\Schema( + * type="array", + * @OA\Items( + * type="string", + * format="binary", + * description="The files to be uploaded", + * ), + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Returns a HTTP status", + * @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\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * + * ), + * @OA\Response( + * response="default", + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ public function edocument(EDocumentRequest $request) { $user = auth()->user(); - foreach($request->file("documents") as $file) { - ImportEDocument::dispatch($file->get(), $file->getClientOriginalName(), $user->company()); + foreach ($request->file("documents") as $file) { + ImportEDocument::dispatch($file->get(), $file->getClientOriginalName(), $request->file("documents")->getMimeType(), $user->company()); } return response()->json(['message' => 'Processing....'], 200); - } } diff --git a/app/Http/Controllers/MailgunController.php b/app/Http/Controllers/MailgunController.php new file mode 100644 index 000000000000..d44b5e3ad2ea --- /dev/null +++ b/app/Http/Controllers/MailgunController.php @@ -0,0 +1,147 @@ +all(); + + nlog($input); + + if (\abs(\time() - $request['signature']['timestamp']) > 15) { + return response()->json(['message' => 'Success'], 200); + } + + if (\hash_equals(\hash_hmac('sha256', $input['signature']['timestamp'] . $input['signature']['token'], config('services.mailgun.webhook_signing_key')), $input['signature']['signature'])) { + ProcessMailgunWebhook::dispatch($request->all())->delay(rand(2, 10)); + } + + return response()->json(['message' => 'Success.'], 200); + } + + /** + * Process Mailgun Inbound Webhook. + * + * IMPORTANT NOTICE: mailgun does NOT strip old sended emails, therefore all past attachements are present + * + * IMPORTANT NOTICE: mailgun saves the message and attachemnts for later retrieval, therefore we can process it within a async job for performance reasons + * + * + * @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"), + * @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 inboundWebhook(Request $request) + { + $input = $request->all(); + + nlog($input); + + if (!array_key_exists('sender', $input) || !array_key_exists('recipient', $input) || !array_key_exists('message-url', $input)) { + nlog('Failed: Message could not be parsed, because required parameters are missing. Please ensure contacting this api-endpoint with a store & notify operation instead of a forward operation!'); + return response()->json(['message' => 'Failed. Missing Parameters. Use store and notify!'], 400); + } + + // @turbo124 TODO: how to check for services.mailgun.webhook_signing_key on company level, when custom credentials are defined + // TODO: validation for client mail credentials by recipient + $authorizedByHash = \hash_equals(\hash_hmac('sha256', $input['timestamp'] . $input['token'], config('services.mailgun.webhook_signing_key')), $input['signature']); + $authorizedByToken = $request->has('token') && $request->get('token') == config('ninja.inbound_mailbox.inbound_webhook_token'); + if (!$authorizedByHash && !$authorizedByToken) + return response()->json(['message' => 'Unauthorized'], 403); + + /** @var \App\Models\Company $company */ + $company = MultiDB::findAndSetDbByExpenseMailbox($input["recipient"]); + + if(!$company) + return response()->json(['message' => 'Ok'], 200); // Fail gracefully + + ProcessMailgunInboundWebhook::dispatch($input["sender"], $input["recipient"], $input["message-url"], $company)->delay(rand(2, 10)); + + return response()->json(['message' => 'Success.'], 200); + } +} diff --git a/app/Http/Controllers/MailgunWebhookController.php b/app/Http/Controllers/MailgunWebhookController.php deleted file mode 100644 index ce51e319cc9e..000000000000 --- a/app/Http/Controllers/MailgunWebhookController.php +++ /dev/null @@ -1,42 +0,0 @@ -all(); - - if (\abs(\time() - $request['signature']['timestamp']) > 15) { - return response()->json(['message' => 'Success'], 200); - } - - if(\hash_equals(\hash_hmac('sha256', $input['signature']['timestamp'] . $input['signature']['token'], config('services.mailgun.webhook_signing_key')), $input['signature']['signature'])) { - ProcessMailgunWebhook::dispatch($request->all())->delay(rand(2, 10)); - } - - return response()->json(['message' => 'Success.'], 200); - } -} diff --git a/app/Http/Controllers/PostMarkController.php b/app/Http/Controllers/PostMarkController.php index 01679cef67f0..5e972cdfcb7a 100644 --- a/app/Http/Controllers/PostMarkController.php +++ b/app/Http/Controllers/PostMarkController.php @@ -12,6 +12,11 @@ namespace App\Http\Controllers; 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; /** @@ -60,11 +65,271 @@ class PostMarkController extends BaseController public function webhook(Request $request) { if ($request->header('X-API-SECURITY') && $request->header('X-API-SECURITY') == config('services.postmark.token')) { - ProcessPostmarkWebhook::dispatch($request->all())->delay(10); + ProcessPostmarkWebhook::dispatch($request->all())->delay(rand(2, 10)); return response()->json(['message' => 'Success'], 200); } return response()->json(['message' => 'Unauthorized'], 403); } + + /** + * Process Postmark Webhook. + * + * IMPORTANT NOTICE: postmark does NOT strip old sended emails, therefore also all past attachements are present + * + * IMPORTANT NOTICE: postmark does not saves attachements for later retrieval, therefore we cannot process it within a async job + * + * @OA\Post( + * path="/api/v1/postmark_inbound_webhook", + * operationId="postmarkInboundWebhook", + * tags={"postmark"}, + * summary="Processing inbound webhooks from PostMark", + * 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"), + * ), + * ) + * + * array ( + * 'FromName' => 'Max Mustermann', + * 'MessageStream' => 'inbound', + * 'From' => 'max@mustermann.de', + * 'FromFull' => + * array ( + * 'Email' => 'max@mustermann.de', + * 'Name' => 'Max Mustermann', + * 'MailboxHash' => NULL, + * ), + * 'To' => '370c69ad9e41d616fc914b3c60250224@inbound.postmarkapp.com', + * 'ToFull' => + * array ( + * 0 => + * array ( + * 'Email' => '370c69ad9e41d616fc914b3c60250224@inbound.postmarkapp.com', + * 'Name' => NULL, + * 'MailboxHash' => NULL, + * ), + * ), + * 'Cc' => NULL, + * 'CcFull' => + * array ( + * ), + * 'Bcc' => NULL, + * 'BccFull' => + * array ( + * ), + * 'OriginalRecipient' => '370c69ad9e41d616fc914b3c60250224@inbound.postmarkapp.com', + * 'Subject' => 'Re: adaw', + * 'MessageID' => 'd37fde00-b4cf-4b64-ac64-e9f6da523c25', + * 'ReplyTo' => NULL, + * 'MailboxHash' => NULL, + * 'Date' => 'Sun, 24 Mar 2024 13:17:52 +0100', + * 'TextBody' => 'wadwad + * + * Am So., 24. März 2024 um 13:17 Uhr schrieb Max Mustermann : + * + * > test + * > + * + * -- + * test.de - Max Mustermann kontakt@test.de + * ', + * 'HtmlBody' => '
wadwad

Am So., 24. März 2024 um 13:17 Uhr schrieb Max Mustermann <max@mustermann.de>:
test
+ *
+ * + *
+ * test.de - Max Mustermann', + * 'StrippedTextReply' => 'wadwad + * + * Am So., 24. März 2024 um 13:17 Uhr schrieb Max Mustermann :', + * 'Tag' => NULL, + * 'Headers' => + * array ( + * 0 => + * array ( + * 'Name' => 'Return-Path', + * 'Value' => '', + * ), + * 1 => + * array ( + * 'Name' => 'Received', + * 'Value' => 'by p-pm-inboundg02a-aws-euwest1a.inbound.postmarkapp.com (Postfix, from userid 996) id 8ED1A453CA4; Sun, 24 Mar 2024 12:18:10 +0000 (UTC)', + * ), + * 2 => + * array ( + * 'Name' => 'X-Spam-Checker-Version', + * 'Value' => 'SpamAssassin 3.4.0 (2014-02-07) on p-pm-inboundg02a-aws-euwest1a', + * ), + * 3 => + * array ( + * 'Name' => 'X-Spam-Status', + * 'Value' => 'No', + * ), + * 4 => + * array ( + * 'Name' => 'X-Spam-Score', + * 'Value' => '-0.1', + * ), + * 5 => + * array ( + * 'Name' => 'X-Spam-Tests', + * 'Value' => 'DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,HTML_MESSAGE, RCVD_IN_DNSWL_NONE,RCVD_IN_MSPIKE_H2,RCVD_IN_ZEN_BLOCKED_OPENDNS, SPF_HELO_NONE,SPF_PASS,URIBL_DBL_BLOCKED_OPENDNS,URIBL_ZEN_BLOCKED_OPENDNS', + * ), + * 6 => + * array ( + * 'Name' => 'Received-SPF', + * 'Value' => 'pass (test.de: Sender is authorized to use \'max@mustermann.de\' in \'mfrom\' identity (mechanism \'include:_spf.google.com\' matched)) receiver=p-pm-inboundg02a-aws-euwest1a; identity=mailfrom; envelope-from="max@mustermann.de"; helo=mail-lf1-f51.google.com; client-ip=209.85.167.51', + * ), + * 7 => + * array ( + * 'Name' => 'Received', + * 'Value' => 'from mail-lf1-f51.google.com (mail-lf1-f51.google.com [209.85.167.51]) (using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) (No client certificate requested) by p-pm-inboundg02a-aws-euwest1a.inbound.postmarkapp.com (Postfix) with ESMTPS id 437BD453CA2 for <370c69ad9e41d616fc914b3c60250224@inbound.postmarkapp.com>; Sun, 24 Mar 2024 12:18:10 +0000 (UTC)', + * ), + * 8 => + * array ( + * 'Name' => 'Received', + * 'Value' => 'by mail-lf1-f51.google.com with SMTP id 2adb3069b0e04-513cf9bacf1so4773866e87.0 for <370c69ad9e41d616fc914b3c60250224@inbound.postmarkapp.com>; Sun, 24 Mar 2024 05:18:10 -0700 (PDT)', + * ), + * 9 => + * array ( + * 'Name' => 'DKIM-Signature', + * 'Value' => 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=test.de; s=google; t=1711282689; x=1711887489; darn=inbound.postmarkapp.com; h=to:subject:message-id:date:from:in-reply-to:references:mime-version :from:to:cc:subject:date:message-id:reply-to; bh=NvjmqLXF/5L5ZrpToR/6FgVOhTOGC9j0/B2Na5Ke6J8=; b=AMXIEoh6yGrOT6X3eBBClQ3NXFNuEoqxeM6aPONsqbpShAcT24iAJmqXylaLHv3fyX Hm6mwp3a029NnrLP/VRyKZbzIMBN2iycidtrEMXF/Eg2e42Q/08/2dZ7nxH6NqE/jz01 3M7qvwHvuoZ2Knhj7rnZc6I5m/nFxBsZc++Aj0Vv9sFoWZZooqAeTXbux1I5NyE17MrL D6byca43iINARZN7XOkoChRRZoZbOqZEtc2Va5yw7v+aYguLB4HHrIFC7G+L8hAJ0IAo 3R3DFeBw58M1xtxXCREI8Y6qMQTw60XyFw0gVmZzqR4hZiTerBSJJsZLZOBgmXxq3WLS +xVQ==', + * ), + * 10 => + * array ( + * 'Name' => 'X-Google-DKIM-Signature', + * 'Value' => 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1711282689; x=1711887489; h=to:subject:message-id:date:from:in-reply-to:references:mime-version :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to; bh=NvjmqLXF/5L5ZrpToR/6FgVOhTOGC9j0/B2Na5Ke6J8=; b=uKoMhir+MX/wycNEr29Sffj45ooKksCJ1OfSRkIIGHk0rnHn8Vh+c7beYipwRPW4F2 h46K64vtIX00guYMdL2Qo2eY96+wALTqHCy67PGhvotVTROz21yxjx62pCDPGs5tefOu IkyxoybpIK8zAfLoDTd9p2GIrr5brKJyB2w1NQc1htxTQ5D4RgBxUAOKv4uVEr8r47iA MIo5d8/AifA+vCOAh7iJ7EmvDQ1R+guhQyH9m1Jo8PLapiYuHXggpBJvooyGuflKqbnt gJ/dscEr4d5aWJbw/x1dmIJ5gyJPGdBWq8NRqV/qbkXQW3H/gylifDUPXbki+EQBD5Yu EuLQ==', + * ), + * 11 => + * array ( + * 'Name' => 'X-Gm-Message-State', + * 'Value' => 'AOJu0Yxpbp1sRh17lNzg+pLnIx1jCn8ZFJQMgFuHK+6Z8RqFS5KKKTxR 8onpEbxWYYVUbrJFExNBHPD/3jdxqifCVVNaDmbpwHgmW5lHLJmA5vYRq5NFZ9OA6zKx/N6Gipr iXE4fXmSqghFNTzy9V/RT08Zp+F5RiFh/Ta6ltQl8XfCPFfSawLz6cagUgt8bBuF4RqdrYmWwzj ty86V5Br1htRNEFYivoXnNmaRcsD0tca1D23ny62O6RwWugrj1IpAYhViNyTZAWu+loKgfjJJoI MsyiSU=', + * ), + * 12 => + * array ( + * 'Name' => 'X-Google-Smtp-Source', + * 'Value' => 'AGHT+IEdtZqbVI6j7WLeaSL3dABGSnWIXaSjbYqXvFvE2H+f2zsn0gknQ4OdTJecQRCabpypVF2ue91Jb7aKl6RiyEQ=', + * ), + * 13 => + * array ( + * 'Name' => 'X-Received', + * 'Value' => 'by 2002:a19:385a:0:b0:513:c876:c80a with SMTP id d26-20020a19385a000000b00513c876c80amr2586776lfj.34.1711282689140; Sun, 24 Mar 2024 05:18:09 -0700 (PDT)', + * ), + * 14 => + * array ( + * 'Name' => 'MIME-Version', + * 'Value' => '1.0', + * ), + * 15 => + * array ( + * 'Name' => 'References', + * 'Value' => '', + * ), + * 16 => + * array ( + * 'Name' => 'In-Reply-To', + * 'Value' => '', + * ), + * 17 => + * array ( + * 'Name' => 'Message-ID', + * 'Value' => '', + * ), + * ), + * 'Attachments' => + * array ( + * array ( + * 'Content' => "base64-String", + * 'ContentLength' => 60164, + * 'Name' => 'Unbenannt.png', + * 'ContentType' => 'image/png', + * 'ContentID' => 'ii_luh2h8lg0', + * ) + * ), + * ) + */ + public function inboundWebhook(Request $request) + { + + $input = $request->all(); + + if (!$request->has('token') || $request->token != config('ninja.inbound_mailbox.inbound_webhook_token')) + return response()->json(['message' => 'Unauthorized'], 403); + + if (!(array_key_exists("MessageStream", $input) && $input["MessageStream"] == "inbound") || !array_key_exists("To", $input) || !array_key_exists("From", $input) || !array_key_exists("MessageID", $input)) { + nlog('Failed: Message could not be parsed, because required parameters are missing.'); + return response()->json(['message' => 'Failed. Missing/Invalid Parameters.'], 400); + } + + $company = MultiDB::findAndSetDbByExpenseMailbox($input["ToFull"][0]["Email"]); + + if (!$company) { + nlog('[PostmarkInboundWebhook] unknown Expense Mailbox occured while handling an inbound email from postmark: ' . $input["To"]); + return response()->json(['message' => 'Ok'], 200); + } + + $inboundEngine = new InboundMailEngine($company); + + if ($inboundEngine->isInvalidOrBlocked($input["From"], $input["ToFull"][0]["Email"])) { + return response()->json(['message' => 'Blocked.'], 403); + } + + try { // important to save meta if something fails here to prevent spam + + // prepare data for ingresEngine + $inboundMail = new InboundMail(); + + $inboundMail->from = $input["From"] ?? ''; + $inboundMail->to = $input["To"]; // usage of data-input, because we need a single email here + $inboundMail->subject = $input["Subject"] ?? ''; + $inboundMail->body = $input["HtmlBody"] ?? ''; + $inboundMail->text_body = $input["TextBody"] ?? ''; + $inboundMail->date = Carbon::createFromTimeString($input["Date"]); + + // parse documents as UploadedFile from webhook-data + foreach ($input["Attachments"] as $attachment) { + + $inboundMail->documents[] = TempFile::UploadedFileFromBase64($attachment["Content"], $attachment["Name"], $attachment["ContentType"]); + + } + + } catch (\Exception $e) { + $inboundEngine->saveMeta($input["From"], $input["To"]); // important to save this, to protect from spam + throw $e; + } + + // perform + try { + + $inboundEngine->handleExpenseMailbox($inboundMail); + + } catch (\Exception $e) { + if ($e->getCode() == 409) + return response()->json(['message' => $e->getMessage()], 409); + + throw $e; + } + + return response()->json(['message' => 'Success'], 200); + } } diff --git a/app/Http/Requests/Company/StoreCompanyRequest.php b/app/Http/Requests/Company/StoreCompanyRequest.php index 442eceec2c16..18d1f1d376ec 100644 --- a/app/Http/Requests/Company/StoreCompanyRequest.php +++ b/app/Http/Requests/Company/StoreCompanyRequest.php @@ -11,14 +11,15 @@ namespace App\Http\Requests\Company; -use App\Utils\Ninja; -use App\Models\Company; -use App\Libraries\MultiDB; use App\Http\Requests\Request; -use App\Utils\Traits\MakesHash; -use App\Http\ValidationRules\ValidSettingsRule; -use App\Http\ValidationRules\Company\ValidSubdomain; use App\Http\ValidationRules\Company\ValidCompanyQuantity; +use App\Http\ValidationRules\Company\ValidExpenseMailbox; +use App\Http\ValidationRules\Company\ValidSubdomain; +use App\Http\ValidationRules\ValidSettingsRule; +use App\Models\Company; +use App\Utils\Ninja; +use App\Libraries\MultiDB; +use App\Utils\Traits\MakesHash; class StoreCompanyRequest extends Request { @@ -56,6 +57,8 @@ class StoreCompanyRequest extends Request } } + $rules['expense_mailbox'] = new ValidExpenseMailbox(); + $rules['smtp_host'] = 'sometimes|string|nullable'; $rules['smtp_port'] = 'sometimes|integer|nullable'; $rules['smtp_encryption'] = 'sometimes|string'; @@ -84,23 +87,27 @@ class StoreCompanyRequest extends Request $input['portal_domain'] = rtrim(strtolower($input['portal_domain']), "/"); } - if(Ninja::isHosted() && !isset($input['subdomain'])) { + if (isset($input['expense_mailbox']) && Ninja::isHosted() && !($this->company->account->isPaid() && $this->company->account->plan == 'enterprise')) { + unset($input['expense_mailbox']); + } + + if (Ninja::isHosted() && !isset($input['subdomain'])) { $input['subdomain'] = MultiDB::randomSubdomainGenerator(); } - if(isset($input['smtp_username']) && strlen(str_replace("*", "", $input['smtp_username'])) < 2) { + if (isset($input['smtp_username']) && strlen(str_replace("*", "", $input['smtp_username'])) < 2) { unset($input['smtp_username']); } - if(isset($input['smtp_password']) && strlen(str_replace("*", "", $input['smtp_password'])) < 2) { + if (isset($input['smtp_password']) && strlen(str_replace("*", "", $input['smtp_password'])) < 2) { unset($input['smtp_password']); } - if(isset($input['smtp_port'])) { + if (isset($input['smtp_port'])) { $input['smtp_port'] = (int) $input['smtp_port']; } - if(isset($input['smtp_verify_peer']) && is_string($input['smtp_verify_peer'])) { + if (isset($input['smtp_verify_peer']) && is_string($input['smtp_verify_peer'])) { $input['smtp_verify_peer'] == 'true' ? true : false; } diff --git a/app/Http/Requests/Company/UpdateCompanyRequest.php b/app/Http/Requests/Company/UpdateCompanyRequest.php index 6c296165ed78..de12d404af5c 100644 --- a/app/Http/Requests/Company/UpdateCompanyRequest.php +++ b/app/Http/Requests/Company/UpdateCompanyRequest.php @@ -14,10 +14,12 @@ namespace App\Http\Requests\Company; use App\Utils\Ninja; use App\Http\Requests\Request; use App\Utils\Traits\MakesHash; +use Illuminate\Validation\Rule; use App\DataMapper\CompanySettings; use App\Http\ValidationRules\ValidSettingsRule; -use App\Http\ValidationRules\EInvoice\ValidCompanyScheme; use App\Http\ValidationRules\Company\ValidSubdomain; +use App\Http\ValidationRules\Company\ValidExpenseMailbox; +use App\Http\ValidationRules\EInvoice\ValidCompanyScheme; class UpdateCompanyRequest extends Request { @@ -75,6 +77,15 @@ class UpdateCompanyRequest extends Request $rules['subdomain'] = ['nullable', 'regex:/^[a-zA-Z0-9.-]+[a-zA-Z0-9]$/', new ValidSubdomain()]; } + $rules['expense_mailbox'] = ['sometimes','email', 'nullable', new ValidExpenseMailbox(), Rule::unique('companies')->ignore($this->company->id)]; + $rules['expense_mailbox_active'] = ['sometimes','boolean']; + $rules['inbound_mailbox_allow_company_users'] = ['sometimes','boolean']; + $rules['inbound_mailbox_allow_vendors'] = ['sometimes','boolean']; + $rules['inbound_mailbox_allow_clients'] = ['sometimes','boolean']; + $rules['inbound_mailbox_allow_unknown'] = ['sometimes','boolean']; + $rules['inbound_mailbox_whitelist'] = ['sometimes', 'string', 'nullable', 'regex:/^[\w\-\.\+]+@([\w-]+\.)+[\w-]{2,4}(,[\w\-\.\+]+@([\w-]+\.)+[\w-]{2,4})*$/']; + $rules['inbound_mailbox_blacklist'] = ['sometimes', 'string', 'nullable', 'regex:/^[\w\-\.\+]+@([\w-]+\.)+[\w-]{2,4}(,[\w\-\.\+]+@([\w-]+\.)+[\w-]{2,4})*$/']; + return $rules; } @@ -87,31 +98,35 @@ class UpdateCompanyRequest extends Request $input['portal_domain'] = rtrim(strtolower($input['portal_domain']), "/"); } - if (isset($input['settings'])) { - $input['settings'] = (array)$this->filterSaveableSettings($input['settings']); + if (isset($input['expense_mailbox']) && Ninja::isHosted() && !($this->company->account->isPaid() && $this->company->account->plan == 'enterprise')) { + unset($input['expense_mailbox']); } - if(isset($input['subdomain']) && $this->company->subdomain == $input['subdomain']) { + if (isset($input['settings'])) { + $input['settings'] = (array) $this->filterSaveableSettings($input['settings']); + } + + if (isset($input['subdomain']) && $this->company->subdomain == $input['subdomain']) { unset($input['subdomain']); } - if(isset($input['e_invoice_certificate_passphrase']) && empty($input['e_invoice_certificate_passphrase'])) { + if (isset($input['e_invoice_certificate_passphrase']) && empty($input['e_invoice_certificate_passphrase'])) { unset($input['e_invoice_certificate_passphrase']); } - if(isset($input['smtp_username']) && strlen(str_replace("*", "", $input['smtp_username'])) < 2) { + if (isset($input['smtp_username']) && strlen(str_replace("*", "", $input['smtp_username'])) < 2) { unset($input['smtp_username']); } - if(isset($input['smtp_password']) && strlen(str_replace("*", "", $input['smtp_password'])) < 2) { + if (isset($input['smtp_password']) && strlen(str_replace("*", "", $input['smtp_password'])) < 2) { unset($input['smtp_password']); } - if(isset($input['smtp_port'])) { - $input['smtp_port'] = (int)$input['smtp_port']; + if (isset($input['smtp_port'])) { + $input['smtp_port'] = (int) $input['smtp_port']; } - if(isset($input['smtp_verify_peer']) && is_string($input['smtp_verify_peer'])) { + if (isset($input['smtp_verify_peer']) && is_string($input['smtp_verify_peer'])) { $input['smtp_verify_peer'] == 'true' ? true : false; } @@ -144,21 +159,21 @@ class UpdateCompanyRequest extends Request } if (isset($settings['email_style_custom'])) { - $settings['email_style_custom'] = str_replace(['{!!','!!}','{{','}}','@checked','@dd', '@dump', '@if', '@if(','@endif','@isset','@unless','@auth','@empty','@guest','@env','@section','@switch', '@foreach', '@while', '@include', '@each', '@once', '@push', '@use', '@forelse', '@verbatim', ' 2) { + if (isset($settings['company_logo']) && strlen($settings['company_logo']) > 2) { $settings['company_logo'] = $this->forceScheme($settings['company_logo']); } - if (! $account->isFreeHostedClient()) { + if (!$account->isFreeHostedClient()) { return $settings; } $saveable_casts = CompanySettings::$free_plan_casts; foreach ($settings as $key => $value) { - if (! array_key_exists($key, $saveable_casts)) { + if (!array_key_exists($key, $saveable_casts)) { unset($settings->{$key}); } } @@ -170,7 +185,7 @@ class UpdateCompanyRequest extends Request { if (Ninja::isHosted()) { $url = str_replace('http://', '', $url); - $url = parse_url($url, PHP_URL_SCHEME) === null ? $scheme.$url : $url; + $url = parse_url($url, PHP_URL_SCHEME) === null ? $scheme . $url : $url; } return rtrim($url, '/'); diff --git a/app/Http/Requests/Expense/EDocumentRequest.php b/app/Http/Requests/Expense/EDocumentRequest.php index 643c3cd6fc33..410c9a716fe8 100644 --- a/app/Http/Requests/Expense/EDocumentRequest.php +++ b/app/Http/Requests/Expense/EDocumentRequest.php @@ -25,9 +25,9 @@ class EDocumentRequest extends Request $rules = []; if ($this->file('documents') && is_array($this->file('documents'))) { - $rules['documents.*'] = 'required|file|max:1000000|mimes:xml'; + $rules['documents.*'] = 'required|file|max:1000000'; } elseif ($this->file('documents')) { - $rules['documents'] = 'required|file|max:1000000|mimes:xml'; + $rules['documents'] = 'required|file|max:1000000'; } return $rules; } diff --git a/app/Http/ValidationRules/Company/ValidExpenseMailbox.php b/app/Http/ValidationRules/Company/ValidExpenseMailbox.php new file mode 100644 index 000000000000..6d3a26a70a60 --- /dev/null +++ b/app/Http/ValidationRules/Company/ValidExpenseMailbox.php @@ -0,0 +1,55 @@ +endings = explode(",", config('ninja.inbound_mailbox.expense_mailbox_endings')); + } + + public function passes($attribute, $value) + { + if (empty($value) || !config('ninja.inbound_mailbox.expense_mailbox_endings')) { + return true; + } + + foreach ($this->endings as $ending) { + if (str_ends_with($value, $ending)) { + return true; + } + } + + return false; + + } + + /** + * @return string + */ + public function message() + { + return ctrans('texts.expense_mailbox_invalid'); + } +} diff --git a/app/Jobs/Brevo/ProcessBrevoInboundWebhook.php b/app/Jobs/Brevo/ProcessBrevoInboundWebhook.php new file mode 100644 index 000000000000..ae6660b7efe3 --- /dev/null +++ b/app/Jobs/Brevo/ProcessBrevoInboundWebhook.php @@ -0,0 +1,244 @@ + + * array ( + * 0 => 'd9f48d52-a344-42a4-9056-9733488d9fa3', + * ), + * 'Recipients' => + * array ( + * 0 => 'test@test.de', + * ), + * 'MessageId' => '', + * 'InReplyTo' => NULL, + * 'From' => + * array ( + * 'Name' => 'Max Mustermann', + * 'Address' => 'max@mustermann.de', + * ), + * 'To' => + * array ( + * 0 => + * array ( + * 'Name' => NULL, + * 'Address' => 'test@test.de', + * ), + * ), + * 'Cc' => + * array ( + * ), + * 'Bcc' => + * array ( + * ), + * 'ReplyTo' => NULL, + * 'SentAtDate' => 'Sat, 23 Mar 2024 18:18:20 +0100', + * 'Subject' => 'TEST', + * 'Attachments' => + * array ( + * 0 => + * array ( + * 'Name' => 'flag--sv-1x1.svg', + * 'ContentType' => 'image/svg+xml', + * 'ContentLength' => 79957, + * 'ContentID' => 'f_lu4ct6s20', + * 'DownloadToken' => 'eyJmb2xkZXIiOiIyMDI0MDMyMzE3MTgzNi45OS43OTgwMDM4MDQiLCJmaWxlbmFtZSI6ImZsYWctLXN2LTF4MS5zdmcifQ', + * ), + * ), + * 'Headers' => + * array ( + * 'Received' => 'by mail-ed1-f51.google.com with SMTP id 4fb4d7f45d1cf-56b0af675deso3877288a12.1 for ; Sat, 23 Mar 2024 10:18:36 -0700 (PDT)', + * 'DKIM-Signature' => 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=mustermann.de; s=google; t=1711214316; x=1711819116; darn=test.de; h=to:subject:message-id:date:from:mime-version:from:to:cc:subject :date:message-id:reply-to; bh=eBSl5M0zvmTd+dFXGXMMSWrQ4nCvUdyVx+1Xpl+YuX8=; b=ackw3d+qTvZk4JKxomvH626MvfwmH23mikOUc2hWwYiO6unmQgPs2w5spnkmD9aCZ9 G+3nPSYKntugOmqWstZH3z4B063U4Y6j5hTc19WtCyyb9UR+XD+C6L10yc6ez8QUhlZT uAGqDoJ+E8+dBxiMul2pow19lC88t3QxRXU+i8zScniV7SFkwzziCEODaB61yI0DXsZB bUkx5Gx6cztKaNVF2QgguF2nQnJFUnD2nabVFsihyJ5r6y61rkSM/YTfMJuES772lnhv IeF+vwiFNEPKafrchce6YJcvo5Vd5lYFK4LtHyCy3mwJpX2QY+WnWAfferZ2YfgEL0Sf K3Pw==', + * 'X-Google-DKIM-Signature' => 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1711214316; x=1711819116; h=to:subject:message-id:date:from:mime-version:x-gm-message-state :from:to:cc:subject:date:message-id:reply-to; bh=eBSl5M0zvmTd+dFXGXMMSWrQ4nCvUdyVx+1Xpl+YuX8=; b=fg4tXZnstRBexYlC6MD7C7is0kQj+xY66cSJ78tSa7PtSFQzY0zajDMsepMCGiiWmN /Pc/tRtk53pru/OtfzRT9pbM6mhM1arIt+QaQBQGU5xZVV5JXfPmdnPzXqAbQztyeHrk UcEkz+qDN3JNoidw2dJhhdt5MxdKssR572NwtBrn/rN7f1o/ThWzEz+P0o06GVBpxVYP wM0EkvcJj2SUOcn36kmp1ccbMUwYCU2h1JmniEFY8RTqu2il13iXoBvG4YPxe0c0hJ6z zw1N5rONeQM113N1rpbQzS1QLSngczuOhN24M3TOwrHJIec/BxrOW6KWl/uPUqiZAf65 f0tg==', + * 'X-Gm-Message-State' => 'AOJu0YzKhR1HY1oUXoq++LLpl6UOz1S60NfPxuPXBLcP+6aACYle8rqQ fYHe2rQYTpg4KWiOswu858STOW8qmiewXD6gH/LbmEFs7sknRyDPNr/+L0cv828A3o+SOvXu3uP SY6H1aNSwIpqTRhJ+nNjTuSUpuSoABd9fYXFwPuivV0DtBhoVmpE=', + * 'X-Google-Smtp-Source' => 'AGHT+IHdA9ZhW0dQxgOYx2OXBGmu4pzSR/zwJ0vcPNXFSqttKCPS2oTw1a9b2mMdhyUeoRAwP5TmhHlAtqUUrOPwkgg=', + * 'X-Received' => 'by 2002:a50:d74c:0:b0:567:3c07:8bbc with SMTP id i12-20020a50d74c000000b005673c078bbcmr2126401edj.21.1711214316135; Sat, 23 Mar 2024 10:18:36 -0700 (PDT)', + * 'MIME-Version' => '1.0', + * 'From' => 'Max Mustermann ', + * 'Date' => 'Sat, 23 Mar 2024 18:18:20 +0100', + * 'Message-ID' => '', + * 'Subject' => 'TEST', + * 'To' => 'test@test.de', + * 'Content-Type' => 'multipart/mixed', + * ), + * 'SpamScore' => 2.8, + * 'ExtractedMarkdownMessage' => 'TEST', + * 'ExtractedMarkdownSignature' => NULL, + * 'RawHtmlBody' => '
TEST
', + * 'RawTextBody' => 'TEST', + * 'EMLDownloadToken' => 'eyJmb2xkZXIiOiIyMDI0MDMyMzE3MTgzNi45OS43OTgwMDM4MDQiLCJmaWxlbmFtZSI6InNtdHAuZW1sIn0', + * ), + * ) + */ + public function __construct(private array $input) + { + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + + $foundOneRecipient = false; // used for spam documentation below + + // brevo defines recipients as array, we check all of them, to be sure + foreach ($this->input["Recipients"] as $recipient) { + + // Spam protection + if ($this->engine->isInvalidOrBlocked($this->input["From"]["Address"], $recipient)) { + return; + } + + // match company + $company = MultiDB::findAndSetDbByExpenseMailbox($recipient); + + if (!$company) { + nlog('[ProcessBrevoInboundWebhook] unknown Expense Mailbox occured while handling an inbound email from brevo: ' . $recipient); + continue; + } + + $this->engine = new InboundMailEngine($company); + + $foundOneRecipient = true; + + try { // important to save meta if something fails here to prevent spam + + if(strlen($company->getSetting('brevo_secret') ?? '') < 2 && empty(config('services.brevo.secret'))){ + nlog("No Brevo Configuration available for this company"); + throw new \Error("[ProcessBrevoInboundWebhook] no brevo credenitals found, we cannot get the attachement"); + } + + $company_brevo_secret = strlen($company->getSetting('brevo_secret') ?? '') < 2 ? $company->getSetting('brevo_secret') : config('services.brevo.secret'); + + // prepare data for ingresEngine + $inboundMail = new InboundMail(); + + $inboundMail->from = $this->input["From"]["Address"]; + $inboundMail->to = $recipient; + $inboundMail->subject = $this->input["Subject"]; + $inboundMail->body = $this->input["RawHtmlBody"]; + $inboundMail->text_body = $this->input["RawTextBody"]; + $inboundMail->date = Carbon::createFromTimeString($this->input["SentAtDate"]); + + // parse documents as UploadedFile from webhook-data + foreach ($this->input["Attachments"] as $attachment) { + + // @todo - i think this allows switching between client configured brevo AND system configured brevo + // download file and save to tmp dir + if (!empty($company_brevo_secret)) + { + + try { + + $brevo = new InboundParsingApi(null, Configuration::getDefaultConfiguration()->setApiKey("api-key", $company_brevo_secret)); + $inboundMail->documents[] = new UploadedFile( + $brevo->getInboundEmailAttachment($attachment["DownloadToken"])->getPathname(), + $attachment["Name"], + $attachment["ContentType"], + 0, + true // Mark it as test, since the file isn't from real HTTP POST. + ); + + } catch (\Error $e) { + if (config('services.brevo.secret')) { + nlog("[ProcessBrevoInboundWebhook] Error while downloading with company credentials, we try to use default credentials now..."); + + $brevo = new InboundParsingApi(null, Configuration::getDefaultConfiguration()->setApiKey("api-key", config('services.brevo.secret'))); + $inboundMail->documents[] = new UploadedFile( + $brevo->getInboundEmailAttachment($attachment["DownloadToken"])->getPathname(), + $attachment["Name"], + $attachment["ContentType"], + 0, + true // Mark it as test, since the file isn't from real HTTP POST. + ); + + } else + throw $e; + } + + } else { + + $brevo = new InboundParsingApi(null, Configuration::getDefaultConfiguration()->setApiKey("api-key", config('services.brevo.secret'))); + $inboundMail->documents[] = new UploadedFile( + $brevo->getInboundEmailAttachment($attachment["DownloadToken"])->getPathname(), + $attachment["Name"], + $attachment["ContentType"], + 0, + true // Mark it as test, since the file isn't from real HTTP POST. + ); + } + + } + + } catch (\Exception $e) { + $this->engine->saveMeta($this->input["From"]["Address"], $recipient); // important to save this, to protect from spam + throw $e; + } + + $this->engine->handleExpenseMailbox($inboundMail); + + } + + // document for spam => mark all recipients as handled emails with unmatched mailbox => otherwise dont do any + if (!$foundOneRecipient) + foreach ($this->input["Recipients"] as $recipient) { + $this->engine->saveMeta($this->input["From"]["Address"], $recipient, true); + } + } + + public function middleware() + { + return [new WithoutOverlapping($this->input["From"]["Address"])]; + } + + public function failed($exception) + { + nlog("BREVO:: Ingest Exception:: => ".$exception->getMessage()); + config(['queue.failed.driver' => null]); + } + + +} diff --git a/app/Jobs/Brevo/ProcessBrevoWebhook.php b/app/Jobs/Brevo/ProcessBrevoWebhook.php index 2c5c0f2b2d5f..84c289a8e806 100644 --- a/app/Jobs/Brevo/ProcessBrevoWebhook.php +++ b/app/Jobs/Brevo/ProcessBrevoWebhook.php @@ -426,7 +426,7 @@ class ProcessBrevoWebhook implements ShouldQueue public function getRawMessage(string $message_id) { - $brevo_secret = !empty($this->company->settings->brevo_secret) ? $this->company->settings->brevo_secret : config('services.brevo.key'); + $brevo_secret = !empty ($this->company->settings->brevo_secret) ? $this->company->settings->brevo_secret : config('services.brevo.secret'); $brevo = new TransactionalEmailsApi(null, Configuration::getDefaultConfiguration()->setApiKey('api-key', $brevo_secret)); $messageDetail = $brevo->getTransacEmailContent($message_id); diff --git a/app/Jobs/EDocument/ImportEDocument.php b/app/Jobs/EDocument/ImportEDocument.php index aacc9b7f35ca..5cf50845f2b3 100644 --- a/app/Jobs/EDocument/ImportEDocument.php +++ b/app/Jobs/EDocument/ImportEDocument.php @@ -11,9 +11,11 @@ namespace App\Jobs\EDocument; +use App\Models\Expense; +use App\Services\EDocument\Imports\ParseEDocument; +use App\Utils\TempFile; use Exception; use App\Models\Company; -use App\Models\Expense; use Illuminate\Bus\Queueable; use Illuminate\Queue\SerializesModels; use Illuminate\Queue\InteractsWithQueue; @@ -31,8 +33,9 @@ class ImportEDocument implements ShouldQueue public $tries = 1; - public function __construct(private readonly string $file_content, private string $file_name, private Company $company) + public function __construct(private readonly string $file_content, private string $file_name, private string $file_mime_type, private Company $company) { + } /** @@ -44,15 +47,9 @@ class ImportEDocument implements ShouldQueue public function handle(): Expense { - switch (true) { - case stristr($this->file_content, "urn:cen.eu:en16931:2017"): - case stristr($this->file_content, "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"): - case stristr($this->file_content, "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_2.1"): - case stristr($this->file_content, "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_2.0"): - return (new ZugferdEDocument($this->file_content, $this->file_name, $this->company))->run(); - default: - throw new Exception("E-Invoice standard not supported"); - } + $file = TempFile::UploadedFileFromRaw($this->file_content, $this->file_name, $this->file_mime_type); + + return (new ParseEDocument($file, $this->company))->run(); } @@ -64,7 +61,7 @@ class ImportEDocument implements ShouldQueue public function failed($exception = null) { if ($exception) { - nlog("EXCEPTION:: ImportEDocument:: ".$exception->getMessage()); + nlog("EXCEPTION:: ImportEDocument:: " . $exception->getMessage()); } config(['queue.failed.driver' => null]); diff --git a/app/Jobs/Mailgun/ProcessMailgunInboundWebhook.php b/app/Jobs/Mailgun/ProcessMailgunInboundWebhook.php new file mode 100644 index 000000000000..84ecc06d5ba2 --- /dev/null +++ b/app/Jobs/Mailgun/ProcessMailgunInboundWebhook.php @@ -0,0 +1,289 @@ +engine = new InboundMailEngine($company); + } + + /** + * Execute the job. + * + * IMPORTANT NOTICE: mailgun does NOT strip old sended emails, therefore all past attachements are present + * + * Mail from Storage + * { + * "Content-Type": "multipart/related; boundary=\"00000000000022bfbe0613e8b7f5\"", + * "Date": "Mon, 18 Mar 2024 06:34:09 +0100", + * "Dkim-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed; d=wer-ner.de; s=google; t=1710740086; x=1711344886; darn=domain.example; h=to:subject:message-id:date:from:in-reply-to:references:mime-version :from:to:cc:subject:date:message-id:reply-to; bh=tkxC+ZzDSJJXLVgjDyvQZyDt6wkWKFHS50z4ZWiWT9U=; b=P1Sz54Djj1LHtPF7+cAKGRaN4IRjUT3bOyYAD/kbC0Tx2yNejPrjCPy3+a6R6MShgJ odYhoLRqylPPs1DQolNO6xgamsoEiR8jnII4QjJUBut4VirMlSO+RLxzpO7pt/Hr6j93 z0G1Yffpbz44l5GhndgXsa4Hf30Q8yy0p7fqMNABB/smscj7DJDu1os2cB1JazKYsmAE X4HtU5IgCOS++xbQPqZSNwjrFWlbgal2t2yAeTKAMdGX/nNKtfgZ5imqNwJWerpAYwgk 3qvUcgTw2MpeghcPpTiflPGp4fT/f1kUjes0dcqrvkE+6oTPvo0pi76QNoVs7peWKr/c JvaA==", + * "From": "Paul Werner ", + * "In-Reply-To": "", + * "Message-Id": "", + * "Mime-Version": "1.0", + * "Received": "by mail-lj1-f175.google.com with SMTP id 38308e7fff4ca-2d4a901e284so12524521fa.1 for ; Sun, 17 Mar 2024 22:34:47 -0700 (PDT)", + * "References": " ", + * "Subject": "Fwd: TEST", + * "To": "test@domain.example", + * "X-Envelope-From": "test@sender.example", + * "X-Gm-Message-State": "AOJu0Yy6rgBIPLGjnD293mVB5vBWQIraVAOnfa/GtyM6S/JIqe4rHbrx OqRe7oFFyCDyCjL/+2AFFkB9ljxgt7MWvpdec69dEn3BNQMlxuyGkpyxZUY8PDm4XRCyIy4vGxK 6Oddl7nWV5DM4zN4eLvZH+DPteyUq9A9ET9bowZnCrP8ZcQOP5js=", + * "X-Google-Dkim-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1710740086; x=1711344886; h=to:subject:message-id:date:from:in-reply-to:references:mime-version :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to; bh=tkxC+ZzDSJJXLVgjDyvQZyDt6wkWKFHS50z4ZWiWT9U=; b=cyDJAeNEaU2CvWAX/d9E3LMDrceXyLe01lbsYvwY6ZNTchr/0vzrxQFTVxos2DQR7u jSKpaNqI958H1oZJY36XZV0+8MY2w6DjB1F3FUHbD1q5gxJUitXNuOvvpna/q0ZaqlQf 5n3kIkakV19uxu4pcrcLxO67744pBzEmVk+IJtI9FEoZy9253v09CfkzNZo68u2VxJVD TDFVVkZuIO5xi3flUVoD3CP0Bw/0BqpDuxVvOFy+qOaItTZ5Na+OPfUJcFG2j6T0rXFQ 1vXPxodqjllLwc/V+O1TmS46H/RhsHGAae5tWk+51KX8T2ZgTkfwKPV1YeSRl0QtDhYS gU0Q==", + * "X-Google-Smtp-Source": "AGHT+IFspt+3tKf94kXs48nOb58GzuV+pJ8oE3ZNwEcx6PG53wJeW858lyh2PiYIzSEPQTY2ykatvu2fqs8Bj+9d5rw=", + * "X-Mailgun-Incoming": "Yes", + * "X-Received": "by 2002:a2e:9847:0:b0:2d4:7455:89f6 with SMTP id e7-20020a2e9847000000b002d4745589f6mr4283454ljj.40.1710740086045; Sun, 17 Mar 2024 22:34:46 -0700 (PDT)", + * "sender": "test@sender.example", + * "recipients": "test@domain.example", + * "from": "Paul Werner ", + * "subject": "Fwd: TEST", + * "body-html": "
TESTAGAIN\"Unbenannt.png\"

---------- Forwarded message ---------
Von: Paul Werner <test@sender.example>
Date: Mo., 18. März 2024 um 06:30 Uhr
Subject: Fwd: TEST
To: <test@domain.example>


Hallöööö

---------- Forwarded message ---------
Von: Paul Werner <test@sender.example>
Date: Mo., 18. März 2024 um 06:23 Uhr
Subject: Fwd: TEST
To: <test@domain.example>


asjkdahwdaiohdawdawdawwwww!!!

---------- Forwarded message ---------
Von: Paul Werner <test@sender.example>
Date: Mo., 18. März 2024 um 06:22 Uhr
Subject: TEST
To: <test@domain.example>


TEST
\r\n
\r\n
\r\n
\r\n", + * "body-plain": "TESTAGAIN[image: Unbenannt.png]\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner \r\nDate: Mo., 18. März 2024 um 06:30 Uhr\r\nSubject: Fwd: TEST\r\nTo: \r\n\r\n\r\nHallöööö\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner \r\nDate: Mo., 18. März 2024 um 06:23 Uhr\r\nSubject: Fwd: TEST\r\nTo: \r\n\r\n\r\nasjkdahwdaiohdawdawdawwwww!!!\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner \r\nDate: Mo., 18. März 2024 um 06:22 Uhr\r\nSubject: TEST\r\nTo: \r\n\r\n\r\nTEST\r\n", + * "attachments": [ + * { + * "name": "Unbenannt.png", + * "content-type": "image/png", + * "size": 197753, + * "url": "https://storage-europe-west1.api.mailgun.net/v3/domains/domain.example/messages/BAAFAgVMamdcBboOIOtFyJ5B5NGEkkffYQ/attachments/0" + * } + * ], + * "content-id-map": { + * "": { + * "name": "Unbenannt.png", + * "content-type": "image/png", + * "size": 197753, + * "url": "https://storage-europe-west1.api.mailgun.net/v3/domains/domain.example/messages/BAAFAgVMamdcBboOIOtFyJ5B5NGEkkffYQ/attachments/0" + * } + * }, + * "message-headers": [ + * [ + * "Received", + * "from mail-lj1-f175.google.com (mail-lj1-f175.google.com [209.85.208.175]) by 634f26f73cf3 with SMTP id (version=TLS1.3, cipher=TLS_AES_128_GCM_SHA256); Mon, 18 Mar 2024 05:34:47 GMT" + * ], + * [ + * "Received", + * "by mail-lj1-f175.google.com with SMTP id 38308e7fff4ca-2d4a901e284so12524521fa.1 for ; Sun, 17 Mar 2024 22:34:47 -0700 (PDT)" + * ], + * [ + * "X-Envelope-From", + * "test@sender.example" + * ], + * [ + * "X-Mailgun-Incoming", + * "Yes" + * ], + * [ + * "Dkim-Signature", + * "v=1; a=rsa-sha256; c=relaxed/relaxed; d=wer-ner.de; s=google; t=1710740086; x=1711344886; darn=domain.example; h=to:subject:message-id:date:from:in-reply-to:references:mime-version :from:to:cc:subject:date:message-id:reply-to; bh=tkxC+ZzDSJJXLVgjDyvQZyDt6wkWKFHS50z4ZWiWT9U=; b=P1Sz54Djj1LHtPF7+cAKGRaN4IRjUT3bOyYAD/kbC0Tx2yNejPrjCPy3+a6R6MShgJ odYhoLRqylPPs1DQolNO6xgamsoEiR8jnII4QjJUBut4VirMlSO+RLxzpO7pt/Hr6j93 z0G1Yffpbz44l5GhndgXsa4Hf30Q8yy0p7fqMNABB/smscj7DJDu1os2cB1JazKYsmAE X4HtU5IgCOS++xbQPqZSNwjrFWlbgal2t2yAeTKAMdGX/nNKtfgZ5imqNwJWerpAYwgk 3qvUcgTw2MpeghcPpTiflPGp4fT/f1kUjes0dcqrvkE+6oTPvo0pi76QNoVs7peWKr/c JvaA==" + * ], + * [ + * "X-Google-Dkim-Signature", + * "v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1710740086; x=1711344886; h=to:subject:message-id:date:from:in-reply-to:references:mime-version :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to; bh=tkxC+ZzDSJJXLVgjDyvQZyDt6wkWKFHS50z4ZWiWT9U=; b=cyDJAeNEaU2CvWAX/d9E3LMDrceXyLe01lbsYvwY6ZNTchr/0vzrxQFTVxos2DQR7u jSKpaNqI958H1oZJY36XZV0+8MY2w6DjB1F3FUHbD1q5gxJUitXNuOvvpna/q0ZaqlQf 5n3kIkakV19uxu4pcrcLxO67744pBzEmVk+IJtI9FEoZy9253v09CfkzNZo68u2VxJVD TDFVVkZuIO5xi3flUVoD3CP0Bw/0BqpDuxVvOFy+qOaItTZ5Na+OPfUJcFG2j6T0rXFQ 1vXPxodqjllLwc/V+O1TmS46H/RhsHGAae5tWk+51KX8T2ZgTkfwKPV1YeSRl0QtDhYS gU0Q==" + * ], + * [ + * "X-Gm-Message-State", + * "AOJu0Yy6rgBIPLGjnD293mVB5vBWQIraVAOnfa/GtyM6S/JIqe4rHbrx OqRe7oFFyCDyCjL/+2AFFkB9ljxgt7MWvpdec69dEn3BNQMlxuyGkpyxZUY8PDm4XRCyIy4vGxK 6Oddl7nWV5DM4zN4eLvZH+DPteyUq9A9ET9bowZnCrP8ZcQOP5js=" + * ], + * [ + * "X-Google-Smtp-Source", + * "AGHT+IFspt+3tKf94kXs48nOb58GzuV+pJ8oE3ZNwEcx6PG53wJeW858lyh2PiYIzSEPQTY2ykatvu2fqs8Bj+9d5rw=" + * ], + * [ + * "X-Received", + * "by 2002:a2e:9847:0:b0:2d4:7455:89f6 with SMTP id e7-20020a2e9847000000b002d4745589f6mr4283454ljj.40.1710740086045; Sun, 17 Mar 2024 22:34:46 -0700 (PDT)" + * ], + * [ + * "Mime-Version", + * "1.0" + * ], + * [ + * "References", + * " " + * ], + * [ + * "In-Reply-To", + * "" + * ], + * [ + * "From", + * "Paul Werner " + * ], + * [ + * "Date", + * "Mon, 18 Mar 2024 06:34:09 +0100" + * ], + * [ + * "Message-Id", + * "" + * ], + * [ + * "Subject", + * "Fwd: TEST" + * ], + * [ + * "To", + * "test@domain.example" + * ], + * [ + * "Content-Type", + * "multipart/related; boundary=\"00000000000022bfbe0613e8b7f5\"" + * ] + * ], + * "stripped-html": "
TESTAGAIN\"Unbenannt.png\"

\n", + * "stripped-text": "TESTAGAIN[image: Unbenannt.png]\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner \r\nDate: Mo., 18. März 2024 um 06:30 Uhr\r\nSubject: Fwd: TEST\r\nTo: \r\n\r\n\r\nHallöööö\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner \r\nDate: Mo., 18. März 2024 um 06:23 Uhr\r\nSubject: Fwd: TEST\r\nTo: \r\n\r\n\r\nasjkdahwdaiohdawdawdawwwww!!!\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner \r\nDate: Mo., 18. März 2024 um 06:22 Uhr\r\nSubject: TEST\r\nTo: \r\n\r\n\r\nTEST", + * "stripped-signature": "" + * } + * @return void + */ + public function handle() + { + $from = $this->sender;//explode("|", $this->input)[0]; + $to = $this->recipient; //explode("|", $this->input)[1]; + // $messageId = explode("|", $this->input)[2]; // used as base in download function + + // Spam protection + if ($this->engine->isInvalidOrBlocked($from, $to)) { + return; + } + + // lets assess this at a higher level to ensure that only valid email inboxes are processed. + // match company + // $company = MultiDB::findAndSetDbByExpenseMailbox($to); + // if (!$company) { + // nlog('[ProcessMailgunInboundWebhook] unknown Expense Mailbox occured while handling an inbound email from mailgun: ' . $to); + // $this->engine->saveMeta($from, $to, true); // important to save this, to protect from spam + // return; + // } + + try { // important to save meta if something fails here to prevent spam + + // fetch message from mailgun-api + $company_mailgun_domain = $this->company->getSetting('email_sending_method') == 'client_mailgun' && strlen($this->company->getSetting('mailgun_domain') ?? '') > 2 ? $this->company->getSetting('mailgun_domain') : null; + $company_mailgun_secret = $this->company->getSetting('email_sending_method') == 'client_mailgun' && strlen($this->company->getSetting('mailgun_secret') ?? '') > 2 ? $this->company->getSetting('mailgun_secret') : null; + if (!($company_mailgun_domain && $company_mailgun_secret) && !(config('services.mailgun.domain') && config('services.mailgun.secret'))) + throw new \Error("[ProcessMailgunInboundWebhook] no mailgun credentials found, we cannot get the attachements and files"); + + $mail = null; + if ($company_mailgun_domain && $company_mailgun_secret) { + + $credentials = $company_mailgun_domain . ":" . $company_mailgun_secret . "@"; + $messageUrl = $this->message_url;//explode("|", $this->input)[2]; + $messageUrl = str_replace("http://", "http://" . $credentials, $messageUrl); + $messageUrl = str_replace("https://", "https://" . $credentials, $messageUrl); + + try { + $mail = json_decode(file_get_contents($messageUrl)); + } catch (\Error $e) { + if (config('services.mailgun.secret')) { + nlog("[ProcessMailgunInboundWebhook] Error while downloading with company credentials, we try to use default credentials now..."); + + $credentials = config('services.mailgun.domain') . ":" . config('services.mailgun.secret') . "@"; + $messageUrl = $this->message_url;//explode("|", $this->input)[2]; + $messageUrl = str_replace("http://", "http://" . $credentials, $messageUrl); + $messageUrl = str_replace("https://", "https://" . $credentials, $messageUrl); + $mail = json_decode(file_get_contents($messageUrl)); + + } else + throw $e; + } + + } else { + + $credentials = config('services.mailgun.domain') . ":" . config('services.mailgun.secret') . "@"; + $messageUrl = $this->message_url; //explode("|", $this->input)[2]; + $messageUrl = str_replace("http://", "http://" . $credentials, $messageUrl); + $messageUrl = str_replace("https://", "https://" . $credentials, $messageUrl); + $mail = json_decode(file_get_contents($messageUrl)); + + } + + // prepare data for ingresEngine + $inboundMail = new InboundMail(); + + $inboundMail->from = $from; + $inboundMail->to = $to; // usage of data-input, because we need a single email here + $inboundMail->subject = $mail->Subject; + $inboundMail->body = $mail->{"body-html"}; + $inboundMail->text_body = $mail->{"body-plain"}; + $inboundMail->date = Carbon::createFromTimeString($mail->Date); + + // parse documents as UploadedFile from webhook-data + foreach ($mail->attachments as $attachment) { // prepare url with credentials before downloading :: https://github.com/mailgun/mailgun.js/issues/24 + + // download file and save to tmp dir + if ($company_mailgun_domain && $company_mailgun_secret) { + + try { + + $credentials = $company_mailgun_domain . ":" . $company_mailgun_secret . "@"; + $url = $attachment->url; + $url = str_replace("http://", "http://" . $credentials, $url); + $url = str_replace("https://", "https://" . $credentials, $url); + $inboundMail->documents[] = TempFile::UploadedFileFromUrl($url, $attachment->name, $attachment->{"content-type"}); + + } catch (\Error $e) { + if (config('services.mailgun.secret')) { + nlog("[ProcessMailgunInboundWebhook] Error while downloading with company credentials, we try to use default credentials now..."); + + $credentials = config('services.mailgun.domain') . ":" . config('services.mailgun.secret') . "@"; + $url = $attachment->url; + $url = str_replace("http://", "http://" . $credentials, $url); + $url = str_replace("https://", "https://" . $credentials, $url); + $inboundMail->documents[] = TempFile::UploadedFileFromUrl($url, $attachment->name, $attachment->{"content-type"}); + + } else + throw $e; + } + + } else { + + $credentials = config('services.mailgun.domain') . ":" . config('services.mailgun.secret') . "@"; + $url = $attachment->url; + $url = str_replace("http://", "http://" . $credentials, $url); + $url = str_replace("https://", "https://" . $credentials, $url); + $inboundMail->documents[] = TempFile::UploadedFileFromUrl($url, $attachment->name, $attachment->{"content-type"}); + + } + + } + + } catch (\Exception $e) { + $this->engine->saveMeta($from, $to); // important to save this, to protect from spam + throw $e; + } + + // perform + $this->engine->handleExpenseMailbox($inboundMail); + } +} diff --git a/app/Libraries/MultiDB.php b/app/Libraries/MultiDB.php index 962bc657f6f7..cc3545b92764 100644 --- a/app/Libraries/MultiDB.php +++ b/app/Libraries/MultiDB.php @@ -73,6 +73,8 @@ class MultiDB 'socket', ]; + private static $protected_expense_mailboxes = []; + /** * @return array */ @@ -84,7 +86,7 @@ class MultiDB public static function checkDomainAvailable($subdomain): bool { - if (! config('ninja.db.multi_db_enabled')) { + if (!config('ninja.db.multi_db_enabled')) { return Company::whereSubdomain($subdomain)->count() == 0; } @@ -107,9 +109,35 @@ class MultiDB return true; } + public static function checkExpenseMailboxAvailable($expense_mailbox): bool + { + + if (!config('ninja.db.multi_db_enabled')) { + return !Company::where("expense_mailbox", $expense_mailbox)->exists(); + } + + if (in_array($expense_mailbox, self::$protected_expense_mailboxes)) { + return false; + } + + $current_db = config('database.default'); + + foreach (self::$dbs as $db) { + if (Company::on($db)->where("expense_mailbox", $expense_mailbox)->exists()) { + self::setDb($current_db); + + return false; + } + } + + self::setDb($current_db); + + return true; + } + public static function checkUserEmailExists($email): bool { - if (! config('ninja.db.multi_db_enabled')) { + if (!config('ninja.db.multi_db_enabled')) { return User::where(['email' => $email])->withTrashed()->exists(); } // true >= 1 emails found / false -> == emails found @@ -170,7 +198,7 @@ class MultiDB */ public static function hasUser(array $data): ?User { - if (! config('ninja.db.multi_db_enabled')) { + if (!config('ninja.db.multi_db_enabled')) { return User::where($data)->withTrashed()->first(); } @@ -194,7 +222,7 @@ class MultiDB */ public static function hasContact(string $email): ?ClientContact { - if (! config('ninja.db.multi_db_enabled')) { + if (!config('ninja.db.multi_db_enabled')) { return ClientContact::where('email', $email)->withTrashed()->first(); } @@ -221,7 +249,7 @@ class MultiDB */ public static function findContact(array $search): ?ClientContact { - if (! config('ninja.db.multi_db_enabled')) { + if (!config('ninja.db.multi_db_enabled')) { return ClientContact::where($search)->first(); } @@ -485,7 +513,7 @@ class MultiDB public static function findAndSetDbByDomain($query_array) { - if (! config('ninja.db.multi_db_enabled')) { + if (!config('ninja.db.multi_db_enabled')) { return Company::where($query_array)->first(); } @@ -504,9 +532,30 @@ class MultiDB return false; } + public static function findAndSetDbByExpenseMailbox($expense_mailbox) + { + if (!config('ninja.db.multi_db_enabled')) { + return Company::where("expense_mailbox", $expense_mailbox)->first(); + } + + $current_db = config('database.default'); + + foreach (self::$dbs as $db) { + if ($company = Company::on($db)->where("expense_mailbox", $expense_mailbox)->first()) { + self::setDb($db); + + return $company; + } + } + + self::setDB($current_db); + + return false; + } + public static function findAndSetByPaymentHash(string $hash) { - if (! config('ninja.db.multi_db_enabled')) { + if (!config('ninja.db.multi_db_enabled')) { return PaymentHash::with('fee_invoice')->where('hash', $hash)->first(); } @@ -527,7 +576,7 @@ class MultiDB public static function findAndSetDbByInvitation($entity, $invitation_key) { - $class = 'App\Models\\'.ucfirst(Str::camel($entity)).'Invitation'; + $class = 'App\Models\\' . ucfirst(Str::camel($entity)) . 'Invitation'; $current_db = config('database.default'); foreach (self::$dbs as $db) { @@ -549,13 +598,13 @@ class MultiDB */ public static function hasPhoneNumber(string $phone): bool { - if (! config('ninja.db.multi_db_enabled')) { + if (!config('ninja.db.multi_db_enabled')) { return Account::where('account_sms_verification_number', $phone)->where('account_sms_verified', true)->exists(); } $current_db = config('database.default'); - if(SMSNumbers::hasNumber($phone)) { // @phpstan-ignore-line + if (SMSNumbers::hasNumber($phone)) { // @phpstan-ignore-line return true; } @@ -583,8 +632,26 @@ class MultiDB $string = ''; $vowels = ['a', 'e', 'i', 'o', 'u', 'y']; $consonants = [ - 'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', - 'n', 'p', 'r', 's', 't', 'v', 'w', 'x', 'y', 'z', + 'b', + 'c', + 'd', + 'f', + 'g', + 'h', + 'j', + 'k', + 'l', + 'm', + 'n', + 'p', + 'r', + 's', + 't', + 'v', + 'w', + 'x', + 'y', + 'z', ]; $max = $length / 2; @@ -592,7 +659,7 @@ class MultiDB $string .= $consonants[rand(0, 19)]; $string .= $vowels[rand(0, 5)]; } - } while (! self::checkDomainAvailable($string)); + } while (!self::checkDomainAvailable($string)); self::setDb($current_db); diff --git a/app/Models/Company.php b/app/Models/Company.php index 7642dbe6e44c..97a567d4ef89 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -112,6 +112,14 @@ use Laracasts\Presenter\PresentableTrait; * @property int $convert_expense_currency * @property int $notify_vendor_when_paid * @property int $invoice_task_hours + * @property string|null $expense_mailbox + * @property boolean $expense_mailbox_active + * @property bool $inbound_mailbox_allow_company_users + * @property bool $inbound_mailbox_allow_vendors + * @property bool $inbound_mailbox_allow_clients + * @property bool $inbound_mailbox_allow_unknown + * @property string|null $inbound_mailbox_whitelist + * @property string|null $inbound_mailbox_blacklist * @property int $deleted_at * @property string|null $smtp_username * @property string|null $smtp_password @@ -362,6 +370,14 @@ class Company extends BaseModel 'calculate_taxes', 'tax_data', 'e_invoice_certificate_passphrase', + 'expense_mailbox_active', + 'expense_mailbox', // TODO: @turbo124 custom validation: self-hosted => free change, hosted => not changeable, only changeable with env-mask + 'inbound_mailbox_allow_company_users', + 'inbound_mailbox_allow_vendors', + 'inbound_mailbox_allow_clients', + 'inbound_mailbox_allow_unknown', + 'inbound_mailbox_whitelist', + 'inbound_mailbox_blacklist', 'smtp_host', 'smtp_port', 'smtp_encryption', @@ -834,24 +850,24 @@ class Company extends BaseModel public function credit_rules() { return BankTransactionRule::query() - ->where('company_id', $this->id) - ->where('applies_to', 'CREDIT') - ->get(); + ->where('company_id', $this->id) + ->where('applies_to', 'CREDIT') + ->get(); } public function debit_rules() { return BankTransactionRule::query() - ->where('company_id', $this->id) - ->where('applies_to', 'DEBIT') - ->get(); + ->where('company_id', $this->id) + ->where('applies_to', 'DEBIT') + ->get(); } public function resolveRouteBinding($value, $field = null) { return $this->where('id', $this->decodePrimaryKey($value)) - ->where('account_id', auth()->user()->account_id) - ->firstOrFail(); + ->where('account_id', auth()->user()->account_id) + ->firstOrFail(); } public function domain(): string @@ -861,7 +877,7 @@ class Company extends BaseModel return $this->portal_domain; } - return "https://{$this->subdomain}.".config('ninja.app_domain'); + return "https://{$this->subdomain}." . config('ninja.app_domain'); } return config('ninja.app_url'); @@ -879,7 +895,7 @@ class Company extends BaseModel public function file_path(): string { - return $this->company_key.'/'; + return $this->company_key . '/'; } public function rBits() @@ -967,7 +983,7 @@ class Company extends BaseModel public function getInvoiceCert() { - if($this->e_invoice_certificate) { + if ($this->e_invoice_certificate) { return base64_decode($this->e_invoice_certificate); } diff --git a/app/Models/SystemLog.php b/app/Models/SystemLog.php index 10eb1f2629a8..a24947a0f6d3 100644 --- a/app/Models/SystemLog.php +++ b/app/Models/SystemLog.php @@ -113,6 +113,8 @@ class SystemLog extends Model public const EVENT_USER = 61; + public const EVENT_INBOUND_MAIL_BLOCKED = 62; + /*Type IDs*/ public const TYPE_PAYPAL = 300; diff --git a/app/Models/Vendor.php b/app/Models/Vendor.php index b10074465027..ba1ee4a098e9 100644 --- a/app/Models/Vendor.php +++ b/app/Models/Vendor.php @@ -181,7 +181,7 @@ class Vendor extends BaseModel /** @var \Illuminate\Support\Collection<\App\Models\Currency> */ $currencies = app('currencies'); - if (! $this->currency_id) { + if (!$this->currency_id) { return $this->company->currency(); } @@ -217,14 +217,14 @@ class Vendor extends BaseModel { $defaults = []; - if (! (array_key_exists('terms', $data) && strlen($data['terms']) > 1)) { - $defaults['terms'] = $this->getSetting($entity_name.'_terms'); + if (!(array_key_exists('terms', $data) && strlen($data['terms']) > 1)) { + $defaults['terms'] = $this->getSetting($entity_name . '_terms'); } elseif (array_key_exists('terms', $data)) { $defaults['terms'] = $data['terms']; } - if (! (array_key_exists('footer', $data) && strlen($data['footer']) > 1)) { - $defaults['footer'] = $this->getSetting($entity_name.'_footer'); + if (!(array_key_exists('footer', $data) && strlen($data['footer']) > 1)) { + $defaults['footer'] = $this->getSetting($entity_name . '_footer'); } elseif (array_key_exists('footer', $data)) { $defaults['footer'] = $data['footer']; } @@ -262,7 +262,7 @@ class Vendor extends BaseModel { $contact_key = $invitation->contact->contact_key; - return $this->company->company_key.'/'.$this->vendor_hash.'/'.$contact_key.'/purchase_orders/'; + return $this->company->company_key . '/' . $this->vendor_hash . '/' . $contact_key . '/purchase_orders/'; } public function locale(): string @@ -287,7 +287,7 @@ class Vendor extends BaseModel public function backup_path(): string { - return $this->company->company_key.'/'.$this->vendor_hash.'/backups'; + return $this->company->company_key . '/' . $this->vendor_hash . '/backups'; } public function service() diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ec849224b615..10afec24789e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -126,18 +126,18 @@ class AppServiceProvider extends ServiceProvider new Dsn( 'brevo+api', 'default', - config('services.brevo.key') + config('services.brevo.secret') ) ); }); - Mailer::macro('brevo_config', function (string $brevo_key) { + Mailer::macro('brevo_config', function (string $brevo_secret) { // @phpstan-ignore /** @phpstan-ignore-next-line **/ Mailer::setSymfonyTransport( (new BrevoTransportFactory())->create( new Dsn( 'brevo+api', 'default', - $brevo_key + $brevo_secret ) ) ); diff --git a/app/Services/EDocument/Imports/MindeeEDocument.php b/app/Services/EDocument/Imports/MindeeEDocument.php new file mode 100644 index 000000000000..0a92b403e48a --- /dev/null +++ b/app/Services/EDocument/Imports/MindeeEDocument.php @@ -0,0 +1,188 @@ +checkLimits(); + + // perform parsing + $mindeeClient = new Client($api_key); + $inputSource = $mindeeClient->sourceFromBytes($this->file->get(), $this->file->getClientOriginalName()); + $result = $mindeeClient->parse(InvoiceV4::class, $inputSource); + $this->incrementRequestCounts(); + + /** @var \Mindee\Product\Invoice\InvoiceV4Document $prediction */ + $prediction = $result->document->inference->prediction; + + if ($prediction->documentType->value !== 'INVOICE') + throw new Exception('Unsupported document type'); + + $grandTotalAmount = $prediction->totalAmount->value; + $documentno = $prediction->invoiceNumber->value; + $documentdate = $prediction->date->value; + $invoiceCurrency = $prediction->locale->currency; + $country = $prediction->locale->country; + + $expense = Expense::query() + ->where('company_id', $this->company->id) + ->where('amount', $grandTotalAmount) + ->where("transaction_reference", $documentno) + ->whereDate("date", $documentdate) + ->first(); + + if (!$expense) { + // The document does not exist as an expense + // Handle accordingly + + /** @var \App\Models\Currency $currency */ + $currency = app('currencies')->first(function ($c) use ($invoiceCurrency) { + /** @var \App\Models\Currency $c */ + return $c->code == $invoiceCurrency; + }); + + $expense = ExpenseFactory::create($this->company->id, $this->company->owner()->id); + $expense->date = $documentdate; + $expense->public_notes = $documentno; + $expense->currency_id = $currency ? $currency->id : $this->company->settings->currency_id; + $expense->save(); + + $this->saveDocuments([ + $this->file, + TempFile::UploadedFileFromRaw(strval($result->document), $documentno . "_mindee_orc_result.txt", "text/plain") + ], $expense); + // $expense->saveQuietly(); + + $expense->uses_inclusive_taxes = True; + $expense->amount = $grandTotalAmount; + $counter = 1; + + foreach ($prediction->taxes as $taxesElem) { + $expense->{"tax_amount{$counter}"} = $taxesElem->value; + $expense->{"tax_rate{$counter}"} = $taxesElem->rate; + $counter++; + } + + /** @var \App\Models\VendorContact $vendor_contact */ + $vendor_contact = VendorContact::query()->where("company_id", $this->company->id)->where("email", $prediction->supplierEmail)->first(); + + /** @var \App\Models\Vendor|null $vendor */ + $vendor = $vendor_contact ? $vendor_contact->vendor : Vendor::query()->where("company_id", $this->company->id)->where("name", $prediction->supplierName)->first(); + + if ($vendor) { + $expense->vendor_id = $vendor->id; + } else { + $vendor = VendorFactory::create($this->company->id, $this->company->owner()->id); + $vendor->name = $prediction->supplierName; + + $vendor->currency_id = Currency::whereCode($invoiceCurrency)->first()?->id; + $vendor->phone = $prediction->supplierPhoneNumber; + // $vendor->address1 = $address_1; // TODO: we only have the full address string from mindee returned + // $vendor->address2 = $address_2; + // $vendor->city = $city; + // $vendor->postal_code = $postcode; + + /** @var ?\App\Models\Country $country */ + $country = app('countries')->first(function ($c) use ($country) { + /** @var \App\Models\Country $c */ + return $c->iso_3166_2 == $country || $c->iso_3166_3 == $country; + }); + + if ($country) + $vendor->country_id = $country->id; + + $vendor->save(); + + if (strlen($prediction->supplierEmail ?? '') > 2) { + $vendor_contact = VendorContactFactory::create($this->company->id, $this->company->owner()->id); + $vendor_contact->vendor_id = $vendor->id; + $vendor_contact->email = $prediction->supplierEmail; + $vendor_contact->save(); + } + + $expense->vendor_id = $vendor->id; + } + $expense->transaction_reference = $documentno; + } else { + // The document exists as an expense + // Handle accordingly + nlog("Mindee: Document already exists"); + $expense->private_notes = $expense->private_notes . ctrans("texts.edocument_import_already_exists", ["date" => now()->format('Y-m-d')]); + } + + $expense->save(); + return $expense; + } + + private function checkLimits() + { + Cache::add('mindeeTotalDailyRequests', 0, now()->endOfDay()); + Cache::add('mindeeTotalMonthlyRequests', 0, now()->endOfMonth()); + Cache::add('mindeeAccountDailyRequests' . $this->company->account->id, 0, now()->endOfDay()); + Cache::add('mindeeAccountMonthlyRequests' . $this->company->account->id, 0, now()->endOfMonth()); + if (config('services.mindee.daily_limit') != 0 && Cache::get('mindeeTotalDailyRequests') > config('services.mindee.daily_limit')) + throw new Exception('Mindee daily limit reached'); + if (config('services.mindee.monthly_limit') != 0 && Cache::get('mindeeTotalMonthlyRequests') > config('services.mindee.monthly_limit')) + throw new Exception('Mindee monthly limit reached'); + if (config('services.mindee.account_daily_limit') != 0 && Cache::get('mindeeAccountDailyRequests' . $this->company->account->id) > config('services.mindee.account_daily_limit')) + throw new Exception('Mindee daily limit reached for account: ' . $this->company->account->id); + if (config('services.mindee.account_monthly_limit') != 0 && Cache::get('mindeeAccountMonthlyRequests' . $this->company->account->id) > config('services.mindee.account_monthly_limit')) + throw new Exception('Mindee monthly limit reached for account: ' . $this->company->account->id); + } + + private function incrementRequestCounts() + { + Cache::increment('mindeeTotalDailyRequests'); + Cache::increment('mindeeTotalMonthlyRequests'); + Cache::increment('mindeeAccountDailyRequests' . $this->company->account->id); + Cache::increment('mindeeAccountMonthlyRequests' . $this->company->account->id); + } +} + diff --git a/app/Services/EDocument/Imports/ParseEDocument.php b/app/Services/EDocument/Imports/ParseEDocument.php new file mode 100644 index 000000000000..b58d4d7664b1 --- /dev/null +++ b/app/Services/EDocument/Imports/ParseEDocument.php @@ -0,0 +1,81 @@ +company->owner()->account; + + $extension = $this->file->getClientOriginalExtension() ?: $this->file->getExtension(); + $mimetype = $this->file->getClientMimeType() ?: $$this->file->getMimeType(); + + // ZUGFERD - try to parse via Zugferd lib + switch (true) { + case ($extension == 'pdf' || $mimetype == 'application/pdf'): + case ($extension == 'xml' || $mimetype == 'application/xml') && stristr($this->file->get(), "urn:cen.eu:en16931:2017"): + case ($extension == 'xml' || $mimetype == 'application/xml') && stristr($this->file->get(), "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"): + case ($extension == 'xml' || $mimetype == 'application/xml') && stristr($this->file->get(), "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_2.1"): + case ($extension == 'xml' || $mimetype == 'application/xml') && stristr($this->file->get(), "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_2.0"): + try { + return (new ZugferdEDocument($this->file, $this->company))->run(); + } catch (Exception $e) { + nlog("Zugferd Exception: " . $e->getMessage()); + } + } + + // MINDEE OCR - try to parse via mindee external service + if (config('services.mindee.api_key') && !(Ninja::isHosted() && !($account->isPaid() && $account->plan == 'enterprise'))) + switch (true) { + case ($extension == 'pdf' || $mimetype == 'application/pdf'): + case ($extension == 'heic' || $extension == 'heic' || $extension == 'png' || $extension == 'jpg' || $extension == 'jpeg' || $extension == 'webp' || str_starts_with($mimetype, 'image/')): + try { + return (new MindeeEDocument($this->file, $this->company))->run(); + } catch (Exception $e) { + if (!($e->getMessage() == 'Unsupported document type')) + nlog("Mindee Exception: " . $e->getMessage()); + } + } + + // NO PARSER OR ERROR + throw new Exception("File type not supported or issue while parsing", 409); + } +} + diff --git a/app/Services/EDocument/Imports/ZugferdEDocument.php b/app/Services/EDocument/Imports/ZugferdEDocument.php index 354b7c5b717c..cc70e4cd6694 100644 --- a/app/Services/EDocument/Imports/ZugferdEDocument.php +++ b/app/Services/EDocument/Imports/ZugferdEDocument.php @@ -11,29 +11,33 @@ namespace App\Services\EDocument\Imports; -use Exception; -use App\Models\Vendor; -use App\Models\Company; -use App\Models\Country; -use App\Models\Expense; -use App\Utils\TempFile; -use App\Models\Currency; -use App\Jobs\Util\UploadFile; -use App\Factory\VendorFactory; use App\Factory\ExpenseFactory; +use App\Factory\VendorFactory; +use App\Jobs\Util\UploadFile; +use App\Models\Country; +use App\Models\Currency; +use App\Models\Expense; +use App\Models\Vendor; +use App\Models\VendorContact; use App\Services\AbstractService; +use App\Utils\TempFile; +use App\Utils\Traits\SavesDocuments; +use Exception; +use App\Models\Company; use horstoeko\zugferd\ZugferdDocumentReader; use horstoeko\zugferdvisualizer\ZugferdVisualizer; use horstoeko\zugferdvisualizer\renderer\ZugferdVisualizerLaravelRenderer; +use Illuminate\Http\UploadedFile; class ZugferdEDocument extends AbstractService { + use SavesDocuments; public ZugferdDocumentReader|string $document; /** * @throws Exception */ - public function __construct(public string $tempdocument, public string $documentname, public Company $company) + public function __construct(public UploadedFile $file, public Company $company) { # curl -X POST http://localhost:8000/api/v1/edocument/upload -H "Content-Type: multipart/form-data" -H "X-API-TOKEN: 7tdDdkz987H3AYIWhNGXy8jTjJIoDhkAclCDLE26cTCj1KYX7EBHC66VEitJwWhn" -H "X-Requested-With: XMLHttpRequest" -F _method=PUT -F documents[]=@einvoice.xml } @@ -46,7 +50,7 @@ class ZugferdEDocument extends AbstractService /** @var \App\Models\User $user */ $user = $this->company->owner(); - $this->document = ZugferdDocumentReader::readAndGuessFromContent($this->tempdocument); + $this->document = ZugferdDocumentReader::readAndGuessFromContent($this->file->get()); $this->document->getDocumentInformation($documentno, $documenttypecode, $documentdate, $invoiceCurrency, $taxCurrency, $documentname, $documentlanguage, $effectiveSpecifiedPeriod); $this->document->getDocumentSummation($grandTotalAmount, $duePayableAmount, $lineTotalAmount, $chargeTotalAmount, $allowanceTotalAmount, $taxBasisTotalAmount, $taxTotalAmount, $roundingAmount, $totalPrepaidAmount); @@ -68,11 +72,13 @@ class ZugferdEDocument extends AbstractService $expense->currency_id = Currency::whereCode($invoiceCurrency)->first()->id ?? $this->company->settings->currency_id; $expense->save(); - $origin_file = TempFile::UploadedFileFromRaw($this->tempdocument, $this->documentname, "application/xml"); - (new UploadFile($origin_file, UploadFile::DOCUMENT, $user, $expense->company, $expense, null, false))->handle(); - $uploaded_file = TempFile::UploadedFileFromRaw($visualizer->renderPdf(), $documentno."_visualiser.pdf", "application/pdf"); - (new UploadFile($uploaded_file, UploadFile::DOCUMENT, $user, $expense->company, $expense, null, false))->handle(); + $documents = [$this->file]; + if ($this->file->getExtension() == "xml") + array_push($documents, TempFile::UploadedFileFromRaw($visualizer->renderPdf(), $documentno . "_visualiser.pdf", "application/pdf")); + $this->saveDocuments($documents, $expense); + $expense->save(); + if ($taxCurrency && $taxCurrency != $invoiceCurrency) { $expense->private_notes = ctrans("texts.tax_currency_mismatch"); } @@ -91,14 +97,25 @@ class ZugferdEDocument extends AbstractService $this->document->getDocumentSellerContact($person_name, $person_department, $contact_phone, $contact_fax, $contact_email); $this->document->getDocumentSellerAddress($address_1, $address_2, $address_3, $postcode, $city, $country, $subdivision); $this->document->getDocumentSellerTaxRegistration($taxtype); + $taxid = null; if (array_key_exists("VA", $taxtype)) { $taxid = $taxtype["VA"]; } - $vendor = Vendor::where('vat_number', $taxid)->first(); - if (!empty($vendor)) { - // Vendor found + $vendor = Vendor::query() + ->where("company_id", $this->company->id) + ->where(function ($q) use($taxid, $person_name, $contact_email){ + $q->when(!is_null($taxid), function ($when_query) use($taxid){ + $when_query->orWhere('vat_number', $taxid); + }) + ->orWhere("name", $person_name) + ->orWhereHas('contacts', function ($qq) use ($contact_email){ + $qq->where("email", $contact_email); + }); + })->first(); + + if ($vendor) { $expense->vendor_id = $vendor->id; } else { $vendor = VendorFactory::create($this->company->id, $user->id); @@ -112,7 +129,12 @@ class ZugferdEDocument extends AbstractService $vendor->address2 = $address_2; $vendor->city = $city; $vendor->postal_code = $postcode; - $vendor->country_id = Country::query()->where('iso_3166_2', $country)->first()->id; + + $country = app('countries')->first(function ($c) use ($country) { + return $c->iso_3166_2 == $country || $c->iso_3166_3 == $country; + }); + if ($country) + $vendor->country_id = $country->id; $vendor->save(); $expense->vendor_id = $vendor->id; @@ -121,7 +143,7 @@ class ZugferdEDocument extends AbstractService } else { // The document exists as an expense // Handle accordingly - nlog("Document already exists"); + nlog("Zugferd: Document already exists"); $expense->private_notes = $expense->private_notes . ctrans("texts.edocument_import_already_exists", ["date" => time()]); } $expense->save(); diff --git a/app/Services/InboundMail/InboundMail.php b/app/Services/InboundMail/InboundMail.php new file mode 100644 index 000000000000..2430df047ed6 --- /dev/null +++ b/app/Services/InboundMail/InboundMail.php @@ -0,0 +1,42 @@ +globalBlacklist = Ninja::isSelfHost() ? explode(",", config('ninja.inbound_mailbox.global_inbound_blocklist')) : []; + $this->globalWhitelist = Ninja::isSelfHost() ? explode(",", config('ninja.inbound_mailbox.global_inbound_whitelist')) : []; + } + + /** + * if there is not a company with an matching mailbox, we only do monitoring + * reuse this method to add more mail-parsing behaviors + */ + public function handleExpenseMailbox(InboundMail $email) + { + if ($this->isInvalidOrBlocked($email->from, $email->to)) + return; + + + // check if company plan matches requirements + if (Ninja::isHosted() && !($this->company->account->isPaid() && $this->company->account->plan == 'enterprise')) { + return; + } + + $this->createExpenses($email); + + $this->saveMeta($email->from, $email->to); + } + + // SPAM Protection + public function isInvalidOrBlocked(string $from, string $to) + { + // invalid email + if (!filter_var($from, FILTER_VALIDATE_EMAIL)) { + nlog('E-Mail blocked, because from e-mail has the wrong format: ' . $from); + return true; + } + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + nlog('E-Mail blocked, because to e-mail has the wrong format: ' . $from); + return true; + } + + $parts = explode('@', $from); + $domain = array_pop($parts); + + // global blacklist + if (in_array($from, $this->globalWhitelist)) { + return false; + } + if (in_array($domain, $this->globalWhitelist)) { + return false; + } + if (in_array($domain, $this->globalBlacklist)) { + nlog('E-Mail blocked, because the domain was found on globalBlocklistDomains: ' . $from); + return true; + } + if (in_array($from, $this->globalBlacklist)) { + nlog('E-Mail blocked, because the email was found on globalBlocklistEmails: ' . $from); + return true; + } + + if (Cache::has('inboundMailBlockedSender:' . $from)) { // was marked as blocked before, so we block without any console output + // nlog('E-Mail was marked as blocked before: ' . $from); + return true; + } + + // sender occured in more than 500 emails in the last 12 hours + $senderMailCountTotal = Cache::get('inboundMailCountSender:' . $from, 0); + if ($senderMailCountTotal >= config('ninja.inbound_mailbox.global_inbound_sender_permablock_mailcount')) { + nlog('E-Mail blocked permanent, because the sender sended more than ' . $senderMailCountTotal . ' emails in the last 12 hours: ' . $from); + $this->blockSender($from); + $this->saveMeta($from, $to); + return true; + } + if ($senderMailCountTotal >= config('ninja.inbound_mailbox.global_inbound_sender_block_mailcount')) { + nlog('E-Mail blocked, because the sender sended more than ' . $senderMailCountTotal . ' emails in the last 12 hours: ' . $from); + $this->saveMeta($from, $to); + return true; + } + + // sender sended more than 50 emails to the wrong mailbox in the last 6 hours + $senderMailCountUnknownRecipent = Cache::get('inboundMailCountSenderUnknownRecipent:' . $from, 0); + if ($senderMailCountUnknownRecipent >= config('ninja.inbound_mailbox.company_inbound_sender_block_unknown_reciepent')) { + nlog('E-Mail blocked, because the sender sended more than ' . $senderMailCountUnknownRecipent . ' emails to the wrong mailbox in the last 6 hours: ' . $from); + $this->saveMeta($from, $to); + return true; + } + + // wrong recipent occurs in more than 100 emails in the last 12 hours, so the processing is blocked + $mailCountUnknownRecipent = Cache::get('inboundMailCountUnknownRecipent:' . $to, 0); // @turbo124 maybe use many to save resources in case of spam with multiple to addresses each time + if ($mailCountUnknownRecipent >= 200) { + nlog('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: ' . $from); + $this->blockSender($from); + $this->saveMeta($from, $to); + return true; + } + + return false; + } + public function blockSender(string $from) + { + Cache::add('inboundMailBlockedSender:' . $from, true, now()->addHours(12)); + + // TODO: ignore, when known sender (for heavy email-usage mostly on isHosted()) + // TODO: handle external blocking + } + + //@todo - refactor + public function saveMeta(string $from, string $to, bool $isUnknownRecipent = false) + { + if(Ninja::isHosted()) + return; + + Cache::add('inboundMailCountSender:' . $from, 0, now()->addHours(12)); + Cache::increment('inboundMailCountSender:' . $from); + + if ($isUnknownRecipent) { + Cache::add('inboundMailCountSenderUnknownRecipent:' . $from, 0, now()->addHours(6)); + Cache::increment('inboundMailCountSenderUnknownRecipent:' . $from); // we save the sender, to may block him + + Cache::add('inboundMailCountUnknownRecipent:' . $to, 0, now()->addHours(12)); + Cache::increment('inboundMailCountUnknownRecipent:' . $to); // we save the sender, to may block him + } + } + + // MAIN-PROCESSORS + protected function createExpenses(InboundMail $email) + { + // Skipping executions: will not result in not saving Metadata to prevent usage of these conditions, to spam + if (!$this->company->expense_mailbox_active) { + $this->logBlocked('mailbox not active for this company. from: ' . $email->from); + return; + } + if (!$this->validateExpenseSender($email)) { + $this->logBlocked('invalid sender of an ingest email for this company. from: ' . $email->from); + return; + } + if (count($email->documents) == 0) { + $this->logBlocked('email does not contain any attachments and is likly not an expense. from: ' . $email->from); + return; + } + + // prepare data + $expense_vendor = $this->getVendor($email); + + $this->processHtmlBodyToDocument($email); + + $parsed_expense_ids = []; // used to check if an expense was already matched within this job + + // check documents => optimal when parsed from any source => else create an expense for each document + foreach ($email->documents as $document) { + + /** @var \App\Models\Expense $expense */ + $expense = null; + + // check if document can be parsed to an expense + try { + + $expense = (new ParseEDocument($document, $this->company))->run(); + + // check if expense was already matched within this job and skip if true + if (array_search($expense->id, $parsed_expense_ids)) + continue; + + array_push($parsed_expense_ids, $expense->id); + + } catch (\Exception $err) { + // throw error, only, when its not expected + switch (true) { + case ($err->getMessage() === 'E-Invoice standard not supported'): + case ($err->getMessage() === 'File type not supported or issue while parsing'): + break; + default: + throw $err; + } + } + + // populate missing data with data from email + if (!$expense) + $expense = ExpenseFactory::create($this->company->id, $this->company->owner()->id); + + $is_imported_by_parser = array_search($expense->id, $parsed_expense_ids); + + if ($is_imported_by_parser) + $expense->public_notes = $expense->public_notes . $email->subject; + + if ($is_imported_by_parser) + $expense->private_notes = $expense->private_notes . $email->text_body; + + if (!$expense->date) + $expense->date = $email->date; + + if (!$expense->vendor_id && $expense_vendor) + $expense->vendor_id = $expense_vendor->id; + + if ($is_imported_by_parser) + $expense->saveQuietly(); + else + $expense->save(); + + // save document only, when not imported by parser + $documents = []; + if (!$is_imported_by_parser) + array_push($documents, $document); + + // email document + if ($email->body_document !== null) + array_push($documents, $email->body_document); + + $this->saveDocuments($documents, $expense); + + } + } + + // HELPERS + private function processHtmlBodyToDocument(InboundMail $email) + { + + if (!is_null($email->body)) + $email->body_document = TempFile::UploadedFileFromRaw($email->body, "E-Mail.html", "text/html"); + + } + private function validateExpenseSender(InboundMail $email) + { + $parts = explode('@', $email->from); + $domain = array_pop($parts); + + // whitelists + $whitelist = explode(",", $this->company->inbound_mailbox_whitelist); + if (is_array($whitelist) && in_array($email->from, $whitelist)) + return true; + if (is_array($whitelist) && in_array($domain, $whitelist)) + return true; + $blacklist = explode(",", $this->company->inbound_mailbox_blacklist); + if (is_array($blacklist) && in_array($email->from, $blacklist)) + return false; + if (is_array($blacklist) && in_array($domain, $blacklist)) + return false; + + // allow unknown + if ($this->company->inbound_mailbox_allow_unknown) + return true; + + // own users + if ($this->company->inbound_mailbox_allow_company_users && $this->company->users()->where("email", $email->from)->exists()) + return true; + + // from vendors + if ($this->company->inbound_mailbox_allow_vendors && VendorContact::where("company_id", $this->company->id)->where("email", $email->from)->exists()) + return true; + + // from clients + if ($this->company->inbound_mailbox_allow_clients && ClientContact::where("company_id", $this->company->id)->where("email", $email->from)->exists()) + return true; + + // denie + return false; + } + + private function getVendor(InboundMail $email) + { + $vendorContact = VendorContact::with('vendor')->where("company_id", $this->company->id)->where("email", $email->from)->first(); + + return $vendorContact ? $vendorContact->vendor : null; + } + + private function logBlocked(string $data) + { + nlog("[InboundMailEngine][company:" . $this->company->company_key . "] " . $data); + + ( + new SystemLogger( + $data, + SystemLog::CATEGORY_MAIL, + SystemLog::EVENT_INBOUND_MAIL_BLOCKED, + SystemLog::TYPE_CUSTOM, + null, + $this->company + ) + )->handle(); + } +} diff --git a/app/Transformers/CompanyTransformer.php b/app/Transformers/CompanyTransformer.php index 39617954fab2..7c28215870b9 100644 --- a/app/Transformers/CompanyTransformer.php +++ b/app/Transformers/CompanyTransformer.php @@ -133,7 +133,7 @@ class CompanyTransformer extends EntityTransformer 'show_product_details' => (bool) $company->show_product_details, 'enable_product_quantity' => (bool) $company->enable_product_quantity, 'default_quantity' => (bool) $company->default_quantity, - 'custom_fields' => (object) $company->custom_fields ?? $std, + 'custom_fields' => (object) $company->custom_fields ?? $std, 'size_id' => (string) $company->size_id ?: '', 'industry_id' => (string) $company->industry_id ?: '', 'first_month_of_year' => (string) $company->first_month_of_year ?: '1', @@ -203,14 +203,22 @@ class CompanyTransformer extends EntityTransformer 'has_e_invoice_certificate_passphrase' => $company->e_invoice_certificate_passphrase ? true : false, 'invoice_task_project_header' => (bool) $company->invoice_task_project_header, 'invoice_task_item_description' => (bool) $company->invoice_task_item_description, - 'origin_tax_data' => $company->origin_tax_data ?: new \stdClass(), - 'smtp_host' => (string)$company->smtp_host ?? '', - 'smtp_port' => (int)$company->smtp_port ?? 25, - 'smtp_encryption' => (string)$company->smtp_encryption ?? 'tls', + 'origin_tax_data' => $company->origin_tax_data ?: new \stdClass, + 'expense_mailbox' => (string) $company->expense_mailbox, + 'expense_mailbox_active' => (bool) $company->expense_mailbox_active, + 'inbound_mailbox_allow_company_users' => (bool) $company->inbound_mailbox_allow_company_users, + 'inbound_mailbox_allow_vendors' => (bool) $company->inbound_mailbox_allow_vendors, + 'inbound_mailbox_allow_clients' => (bool) $company->inbound_mailbox_allow_clients, + 'inbound_mailbox_allow_unknown' => (bool) $company->inbound_mailbox_allow_unknown, + 'inbound_mailbox_blacklist' => (string) $company->inbound_mailbox_blacklist, + 'inbound_mailbox_whitelist' => (string) $company->inbound_mailbox_whitelist, + 'smtp_host' => (string) $company->smtp_host ?? '', + 'smtp_port' => (int) $company->smtp_port ?? 25, + 'smtp_encryption' => (string) $company->smtp_encryption ?? 'tls', 'smtp_username' => $company->smtp_username ? '********' : '', 'smtp_password' => $company->smtp_password ? '********' : '', - 'smtp_local_domain' => (string)$company->smtp_local_domain ?? '', - 'smtp_verify_peer' => (bool)$company->smtp_verify_peer, + 'smtp_local_domain' => (string) $company->smtp_local_domain ?? '', + 'smtp_verify_peer' => (bool) $company->smtp_verify_peer, 'e_invoice' => $company->e_invoice ?: new \stdClass(), 'has_quickbooks_token' => $company->quickbooks ? true : false, 'is_quickbooks_token_active' => $company->quickbooks?->accessTokenKey ?? false, diff --git a/app/Transformers/VendorTransformer.php b/app/Transformers/VendorTransformer.php index 3a29f29584f2..0dc7cfd9aa12 100644 --- a/app/Transformers/VendorTransformer.php +++ b/app/Transformers/VendorTransformer.php @@ -104,7 +104,7 @@ class VendorTransformer extends EntityTransformer 'number' => (string) $vendor->number ?: '', 'language_id' => (string) $vendor->language_id ?: '', 'classification' => (string) $vendor->classification ?: '', - 'display_name' => (string) $vendor->present()->name(), + 'display_name' => (string) $vendor->present()->name(), 'routing_id' => (string) $vendor->routing_id ?: '', ]; } diff --git a/app/Utils/TempFile.php b/app/Utils/TempFile.php index 1d2498d9f929..917650dc7612 100644 --- a/app/Utils/TempFile.php +++ b/app/Utils/TempFile.php @@ -13,12 +13,13 @@ namespace App\Utils; use Illuminate\Http\File; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Arr; class TempFile { public static function path($url): string { - $temp_path = @tempnam(sys_get_temp_dir().'/'.sha1((string)time()), basename($url)); + $temp_path = @tempnam(sys_get_temp_dir() . '/' . sha1((string) time()), basename($url)); copy($url, $temp_path); return $temp_path; @@ -27,17 +28,50 @@ class TempFile /* Downloads a file to temp storage and returns the path - used for mailers */ public static function filePath($data, $filename): string { - $dir_hash = sys_get_temp_dir().'/'.sha1(microtime()); + $dir_hash = sys_get_temp_dir() . '/' . sha1(microtime()); mkdir($dir_hash); - $file_path = $dir_hash.'/'.$filename; + $file_path = $dir_hash . '/' . $filename; file_put_contents($file_path, $data); return $file_path; } + /* create a tmp file from a base64 string: https://gist.github.com/waska14/8b3bcebfad1f86f7fcd3b82927576e38*/ + public static function UploadedFileFromBase64(string $base64File, string|null $fileName = null, string|null $mimeType = null): UploadedFile + { + // Get file data base64 string + $fileData = base64_decode(Arr::last(explode(',', $base64File))); + + // 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, $fileData); + + $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; + } + + /* create a tmp file from a raw string: https://gist.github.com/waska14/8b3bcebfad1f86f7fcd3b82927576e38*/ public static function UploadedFileFromRaw(string $fileData, string|null $fileName = null, string|null $mimeType = null): UploadedFile { // Create temp file and get its absolute path diff --git a/composer.json b/composer.json index ae27d3796cb0..87b33403ad4e 100644 --- a/composer.json +++ b/composer.json @@ -76,7 +76,9 @@ "league/flysystem-aws-s3-v3": "^3.0", "league/fractal": "^0.20.0", "livewire/livewire": "^3", + "mailgun/mailgun-php": "^3.6", "microsoft/microsoft-graph": "^1.69", + "mindee/mindee": "^1.8", "mollie/mollie-api-php": "^2.36", "nelexa/zip": "^4.0", "nordigen/nordigen-php": "^1.1", @@ -207,4 +209,4 @@ ], "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 3d77b47a7d61..09da42d6cc1e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3d0c51f983bca2b17f021bdfbf781245", + "content-hash": "a2e8043b8cfa0f971360c3bc8d36fa2c", "packages": [ { "name": "adrienrn/php-mimetyper", @@ -852,16 +852,16 @@ }, { "name": "btcpayserver/btcpayserver-greenfield-php", - "version": "v2.6.0", + "version": "v2.7.0", "source": { "type": "git", "url": "https://github.com/btcpayserver/btcpayserver-greenfield-php.git", - "reference": "c115b0415719b9fe6e35d5df5f291646d4af2240" + "reference": "5e2ba7e3f585fc8e6dc068e22a0efbfdacd9c992" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/btcpayserver/btcpayserver-greenfield-php/zipball/c115b0415719b9fe6e35d5df5f291646d4af2240", - "reference": "c115b0415719b9fe6e35d5df5f291646d4af2240", + "url": "https://api.github.com/repos/btcpayserver/btcpayserver-greenfield-php/zipball/5e2ba7e3f585fc8e6dc068e22a0efbfdacd9c992", + "reference": "5e2ba7e3f585fc8e6dc068e22a0efbfdacd9c992", "shasum": "" }, "require": { @@ -900,9 +900,9 @@ "description": "BTCPay Server Greenfield API PHP client library.", "support": { "issues": "https://github.com/btcpayserver/btcpayserver-greenfield-php/issues", - "source": "https://github.com/btcpayserver/btcpayserver-greenfield-php/tree/v2.6.0" + "source": "https://github.com/btcpayserver/btcpayserver-greenfield-php/tree/v2.7.0" }, - "time": "2024-04-25T09:19:49+00:00" + "time": "2024-09-13T14:54:13+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -1041,6 +1041,72 @@ }, "time": "2024-08-29T07:34:57+00:00" }, + { + "name": "clue/stream-filter", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/clue/stream-filter.git", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "Clue\\StreamFilter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "A simple and modern approach to stream filtering in PHP", + "homepage": "https://github.com/clue/stream-filter", + "keywords": [ + "bucket brigade", + "callback", + "filter", + "php_user_filter", + "stream", + "stream_filter_append", + "stream_filter_register" + ], + "support": { + "issues": "https://github.com/clue/stream-filter/issues", + "source": "https://github.com/clue/stream-filter/tree/v1.7.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2023-12-20T15:40:13+00:00" + }, { "name": "composer/ca-bundle", "version": "1.5.1", @@ -3489,16 +3555,16 @@ }, { "name": "horstoeko/zugferd", - "version": "v1.0.60", + "version": "v1.0.61", "source": { "type": "git", "url": "https://github.com/horstoeko/zugferd.git", - "reference": "10c2296f35060e2c748d8951c97fcb8496c843de" + "reference": "e3c0d2b3c2aa84716bfbe235a15870f3798246e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/horstoeko/zugferd/zipball/10c2296f35060e2c748d8951c97fcb8496c843de", - "reference": "10c2296f35060e2c748d8951c97fcb8496c843de", + "url": "https://api.github.com/repos/horstoeko/zugferd/zipball/e3c0d2b3c2aa84716bfbe235a15870f3798246e1", + "reference": "e3c0d2b3c2aa84716bfbe235a15870f3798246e1", "shasum": "" }, "require": { @@ -3558,9 +3624,9 @@ ], "support": { "issues": "https://github.com/horstoeko/zugferd/issues", - "source": "https://github.com/horstoeko/zugferd/tree/v1.0.60" + "source": "https://github.com/horstoeko/zugferd/tree/v1.0.61" }, - "time": "2024-09-06T10:26:03+00:00" + "time": "2024-09-12T14:13:26+00:00" }, { "name": "horstoeko/zugferdvisualizer", @@ -3938,12 +4004,12 @@ "source": { "type": "git", "url": "https://github.com/invoiceninja/einvoice.git", - "reference": "1ec178ec134981629932aae12677e947ee3df091" + "reference": "fe13a98c970bc604d4467f34476109745d2602ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/invoiceninja/einvoice/zipball/1ec178ec134981629932aae12677e947ee3df091", - "reference": "1ec178ec134981629932aae12677e947ee3df091", + "url": "https://api.github.com/repos/invoiceninja/einvoice/zipball/fe13a98c970bc604d4467f34476109745d2602ca", + "reference": "fe13a98c970bc604d4467f34476109745d2602ca", "shasum": "" }, "require": { @@ -3985,7 +4051,7 @@ "source": "https://github.com/invoiceninja/einvoice/tree/main", "issues": "https://github.com/invoiceninja/einvoice/issues" }, - "time": "2024-08-28T07:20:26+00:00" + "time": "2024-09-11T01:50:30+00:00" }, { "name": "invoiceninja/inspector", @@ -4490,16 +4556,16 @@ }, { "name": "laravel/framework", - "version": "v11.22.0", + "version": "v11.23.5", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "868c75beacc47d0f361b919bbc155c0b619bf3d5" + "reference": "16b31ab0e1dad5cb2ed6dcc1818c02f02fc48453" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/868c75beacc47d0f361b919bbc155c0b619bf3d5", - "reference": "868c75beacc47d0f361b919bbc155c0b619bf3d5", + "url": "https://api.github.com/repos/laravel/framework/zipball/16b31ab0e1dad5cb2ed6dcc1818c02f02fc48453", + "reference": "16b31ab0e1dad5cb2ed6dcc1818c02f02fc48453", "shasum": "" }, "require": { @@ -4561,6 +4627,7 @@ "illuminate/bus": "self.version", "illuminate/cache": "self.version", "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", "illuminate/conditionable": "self.version", "illuminate/config": "self.version", "illuminate/console": "self.version", @@ -4603,7 +4670,7 @@ "league/flysystem-sftp-v3": "^3.0", "mockery/mockery": "^1.6", "nyholm/psr7": "^1.2", - "orchestra/testbench-core": "^9.1.5", + "orchestra/testbench-core": "^9.4.0", "pda/pheanstalk": "^5.0", "phpstan/phpstan": "^1.11.5", "phpunit/phpunit": "^10.5|^11.0", @@ -4661,6 +4728,7 @@ "src/Illuminate/Events/functions.php", "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", "src/Illuminate/Support/helpers.php" ], "psr-4": { @@ -6104,6 +6172,67 @@ ], "time": "2023-06-21T14:59:35+00:00" }, + { + "name": "mailgun/mailgun-php", + "version": "v3.6.3", + "source": { + "type": "git", + "url": "https://github.com/mailgun/mailgun-php.git", + "reference": "3dbdc2f220fa64e78e903477efa22858c72509be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mailgun/mailgun-php/zipball/3dbdc2f220fa64e78e903477efa22858c72509be", + "reference": "3dbdc2f220fa64e78e903477efa22858c72509be", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8.0", + "php-http/client-common": "^2.2.1", + "php-http/discovery": "^1.19", + "php-http/multipart-stream-builder": "^1.1.2", + "psr/http-client": "^1.0", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "nyholm/nsa": "^1.2.1", + "nyholm/psr7": "^1.3.1", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.7", + "symfony/http-client": "^5.4 || ^6.3" + }, + "suggest": { + "nyholm/psr7": "PSR-7 message implementation", + "symfony/http-client": "HTTP client" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Mailgun\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Travis Swientek", + "email": "travis@mailgunhq.com" + } + ], + "description": "The Mailgun SDK provides methods for all API functions.", + "support": { + "issues": "https://github.com/mailgun/mailgun-php/issues", + "source": "https://github.com/mailgun/mailgun-php/tree/v3.6.3" + }, + "time": "2023-12-01T10:04:01+00:00" + }, { "name": "markbaker/complex", "version": "3.0.2", @@ -6331,17 +6460,72 @@ "time": "2024-01-15T18:49:30+00:00" }, { - "name": "mollie/mollie-api-php", - "version": "v2.71.0", + "name": "mindee/mindee", + "version": "v1.10.0", "source": { "type": "git", - "url": "https://github.com/mollie/mollie-api-php.git", - "reference": "dff324f0621ff134fbefffa42ee511833a58578f" + "url": "https://github.com/mindee/mindee-api-php.git", + "reference": "40865a03e34bb2416b32e5e1dd4937020e7bcc27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mollie/mollie-api-php/zipball/dff324f0621ff134fbefffa42ee511833a58578f", - "reference": "dff324f0621ff134fbefffa42ee511833a58578f", + "url": "https://api.github.com/repos/mindee/mindee-api-php/zipball/40865a03e34bb2416b32e5e1dd4937020e7bcc27", + "reference": "40865a03e34bb2416b32e5e1dd4937020e7bcc27", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-fileinfo": "*", + "ext-json": "*", + "php": ">=7.4", + "setasign/fpdf": "^1.8", + "setasign/fpdi": "^2.6", + "spatie/pdf-to-image": "^1.2", + "symfony/console": ">=5.4" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.38", + "madewithlove/license-checker": "^0.10.0", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.7" + }, + "suggest": { + "ext-imagick": "Required for PDF rasterization and image processing features", + "ghostscript/ghostscript": "Required for PDF rasterization features" + }, + "bin": [ + "mindee", + "bin/cli.php" + ], + "type": "library", + "autoload": { + "psr-4": { + "Mindee\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Mindee Client Library for PHP", + "support": { + "issues": "https://github.com/mindee/mindee-api-php/issues", + "source": "https://github.com/mindee/mindee-api-php/tree/v1.10.0" + }, + "time": "2024-09-04T15:40:29+00:00" + }, + { + "name": "mollie/mollie-api-php", + "version": "v2.72.0", + "source": { + "type": "git", + "url": "https://github.com/mollie/mollie-api-php.git", + "reference": "cdfb298ff61737a077554c001e936e6134e7ed8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mollie/mollie-api-php/zipball/cdfb298ff61737a077554c001e936e6134e7ed8e", + "reference": "cdfb298ff61737a077554c001e936e6134e7ed8e", "shasum": "" }, "require": { @@ -6418,9 +6602,9 @@ ], "support": { "issues": "https://github.com/mollie/mollie-api-php/issues", - "source": "https://github.com/mollie/mollie-api-php/tree/v2.71.0" + "source": "https://github.com/mollie/mollie-api-php/tree/v2.72.0" }, - "time": "2024-07-17T08:02:14+00:00" + "time": "2024-09-11T15:06:31+00:00" }, { "name": "monolog/monolog", @@ -7352,27 +7536,27 @@ }, { "name": "nwidart/laravel-modules", - "version": "v11.0.11", + "version": "v11.1.0", "source": { "type": "git", "url": "https://github.com/nWidart/laravel-modules.git", - "reference": "9d50adcbf8d11c9ec01e48a5b7adbf320653185c" + "reference": "2ae13812f055a85d7063e90366884cd327877821" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nWidart/laravel-modules/zipball/9d50adcbf8d11c9ec01e48a5b7adbf320653185c", - "reference": "9d50adcbf8d11c9ec01e48a5b7adbf320653185c", + "url": "https://api.github.com/repos/nWidart/laravel-modules/zipball/2ae13812f055a85d7063e90366884cd327877821", + "reference": "2ae13812f055a85d7063e90366884cd327877821", "shasum": "" }, "require": { "ext-json": "*", - "laravel/pint": "^1.16", "php": ">=8.2", "wikimedia/composer-merge-plugin": "^2.1" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.52", "laravel/framework": "^v11.0", + "laravel/pint": "^1.16", "mockery/mockery": "^1.6", "orchestra/testbench": "^v9.0", "phpstan/phpstan": "^1.4", @@ -7423,7 +7607,7 @@ ], "support": { "issues": "https://github.com/nWidart/laravel-modules/issues", - "source": "https://github.com/nWidart/laravel-modules/tree/v11.0.11" + "source": "https://github.com/nWidart/laravel-modules/tree/v11.1.0" }, "funding": [ { @@ -7435,7 +7619,7 @@ "type": "github" } ], - "time": "2024-06-16T16:26:29+00:00" + "time": "2024-09-13T19:24:08+00:00" }, { "name": "nyholm/psr7", @@ -7808,6 +7992,388 @@ }, "time": "2024-04-08T12:52:34+00:00" }, + { + "name": "php-http/client-common", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/client-common.git", + "reference": "1e19c059b0e4d5f717bf5d524d616165aeab0612" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/client-common/zipball/1e19c059b0e4d5f717bf5d524d616165aeab0612", + "reference": "1e19c059b0e4d5f717bf5d524d616165aeab0612", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/httplug": "^2.0", + "php-http/message": "^1.6", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "doctrine/instantiator": "^1.1", + "guzzlehttp/psr7": "^1.4", + "nyholm/psr7": "^1.2", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "phpspec/prophecy": "^1.10.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7" + }, + "suggest": { + "ext-json": "To detect JSON responses with the ContentTypePlugin", + "ext-libxml": "To detect XML responses with the ContentTypePlugin", + "php-http/cache-plugin": "PSR-6 Cache plugin", + "php-http/logger-plugin": "PSR-3 Logger plugin", + "php-http/stopwatch-plugin": "Symfony Stopwatch plugin" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\Common\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Common HTTP Client implementations and tools for HTTPlug", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "common", + "http", + "httplug" + ], + "support": { + "issues": "https://github.com/php-http/client-common/issues", + "source": "https://github.com/php-http/client-common/tree/2.7.1" + }, + "time": "2023-11-30T10:31:25+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.19.4", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "0700efda8d7526335132360167315fdab3aeb599" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/0700efda8d7526335132360167315fdab3aeb599", + "reference": "0700efda8d7526335132360167315fdab3aeb599", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.19.4" + }, + "time": "2024-03-29T13:00:05+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "625ad742c360c8ac580fcc647a1541d29e257f67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/625ad742c360c8ac580fcc647a1541d29e257f67", + "reference": "625ad742c360c8ac580fcc647a1541d29e257f67", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.0" + }, + "time": "2023-04-14T15:10:03+00:00" + }, + { + "name": "php-http/message", + "version": "1.16.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/message.git", + "reference": "5997f3289332c699fa2545c427826272498a2088" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message/zipball/5997f3289332c699fa2545c427826272498a2088", + "reference": "5997f3289332c699fa2545c427826272498a2088", + "shasum": "" + }, + "require": { + "clue/stream-filter": "^1.5", + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.6", + "ext-zlib": "*", + "guzzlehttp/psr7": "^1.0 || ^2.0", + "laminas/laminas-diactoros": "^2.0 || ^3.0", + "php-http/message-factory": "^1.0.2", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "slim/slim": "^3.0" + }, + "suggest": { + "ext-zlib": "Used with compressor/decompressor streams", + "guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories", + "laminas/laminas-diactoros": "Used with Diactoros Factories", + "slim/slim": "Used with Slim Framework PSR-7 implementation" + }, + "type": "library", + "autoload": { + "files": [ + "src/filters.php" + ], + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "HTTP Message related tools", + "homepage": "http://php-http.org", + "keywords": [ + "http", + "message", + "psr-7" + ], + "support": { + "issues": "https://github.com/php-http/message/issues", + "source": "https://github.com/php-http/message/tree/1.16.1" + }, + "time": "2024-03-07T13:22:09+00:00" + }, + { + "name": "php-http/multipart-stream-builder", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/multipart-stream-builder.git", + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/10086e6de6f53489cca5ecc45b6f468604d3460e", + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/discovery": "^1.15", + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "php-http/message": "^1.5", + "php-http/message-factory": "^1.0.2", + "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Message\\MultipartStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "A builder class that help you create a multipart stream", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "multipart stream", + "stream" + ], + "support": { + "issues": "https://github.com/php-http/multipart-stream-builder/issues", + "source": "https://github.com/php-http/multipart-stream-builder/tree/1.4.2" + }, + "time": "2024-09-04T13:22:54+00:00" + }, + { + "name": "php-http/promise", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" + }, + "time": "2024-03-15T13:55:21+00:00" + }, { "name": "php-jsonpointer/php-jsonpointer", "version": "v3.0.2", @@ -8850,16 +9416,16 @@ }, { "name": "psr/log", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "79dff0b268932c640297f5208d6298f71855c03e" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e", - "reference": "79dff0b268932c640297f5208d6298f71855c03e", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { @@ -8894,9 +9460,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.1" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2024-08-21T13:31:24+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { "name": "psr/simple-cache", @@ -10272,6 +10838,66 @@ }, "time": "2024-07-12T02:43:55+00:00" }, + { + "name": "spatie/pdf-to-image", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/pdf-to-image.git", + "reference": "9a5cb264a99e87e010c65d4ece03b51f821d55bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/pdf-to-image/zipball/9a5cb264a99e87e010c65d4ece03b51f821d55bd", + "reference": "9a5cb264a99e87e010c65d4ece03b51f821d55bd", + "shasum": "" + }, + "require": { + "php": ">=5.5.0" + }, + "require-dev": { + "phpunit/phpunit": "4.*", + "scrutinizer/ocular": "~1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\PdfToImage\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Convert a pdf to an image", + "homepage": "https://github.com/spatie/pdf-to-image", + "keywords": [ + "convert", + "image", + "pdf", + "pdf-to-image", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/pdf-to-image/issues", + "source": "https://github.com/spatie/pdf-to-image/tree/1.2.2" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2016-12-14T15:37:00+00:00" + }, { "name": "sprain/swiss-qr-bill", "version": "v4.14", diff --git a/config/ninja.php b/config/ninja.php index e20e167bb412..5f8576149ef5 100644 --- a/config/ninja.php +++ b/config/ninja.php @@ -82,11 +82,11 @@ return [ 'username' => 'user@example.com', 'clientname' => 'client@example.com', 'password' => 'password', - 'gocardless' => env('GOCARDLESS_KEYS',''), - 'square' => env('SQUARE_KEYS',''), - 'eway' => env('EWAY_KEYS',''), - 'mollie', env('MOLLIE_KEYS',''), - 'paytrace' => env('PAYTRACE_KEYS',''), + 'gocardless' => env('GOCARDLESS_KEYS', ''), + 'square' => env('SQUARE_KEYS', ''), + 'eway' => env('EWAY_KEYS', ''), + 'mollie' => env('MOLLIE_KEYS', ''), + 'paytrace' => env('PAYTRACE_KEYS', ''), 'stripe' => env('STRIPE_KEYS', ''), 'paypal' => env('PAYPAL_KEYS', ''), 'ppcp' => env('PPCP_KEYS', ''), @@ -98,7 +98,6 @@ return [ 'test_email' => env('TEST_EMAIL', 'test@example.com'), 'wepay' => env('WEPAY_KEYS', ''), 'braintree' => env('BRAINTREE_KEYS', ''), - 'mollie' => env('MOLLIE_KEYS', ''), ], 'contact' => [ 'email' => env('MAIL_FROM_ADDRESS'), @@ -232,6 +231,17 @@ return [ 'client_id' => env('PAYPAL_CLIENT_ID', null), 'webhook_id' => env('PAYPAL_WEBHOOK_ID', null), ], + 'inbound_mailbox' => [ + 'expense_mailbox_endings' => env('EXPENSE_MAILBOX_ENDINGS', false), + // 'expense_mailbox_endings' => env('EXPENSE_MAILBOX_ENDINGS', '@expense.invoicing.co'), + 'inbound_webhook_token' => env('INBOUND_WEBHOOK_TOKEN', null), + 'global_inbound_blacklist' => env('GLOBAL_INBOUND_BLACKLIST', ''), + 'global_inbound_whitelist' => env('GLOBAL_INBOUND_WHITELIST', ''), + 'global_inbound_sender_block_mailcount' => env('GLOBAL_INBOUND_SENDER_BLOCK_MAILCOUNT', 1000), + 'global_inbound_sender_permablock_mailcount' => env('GLOBAL_INBOUND_SENDER_PERMABLOCK_MAILCOUNT', 5000), + 'company_inbound_sender_block_unknown_reciepent' => env('COMPANY_INBOUND_SENDER_BLOCK_UNKNOWN_RECIEPENT', 50), + 'global_inbound_sender_permablock_unknown_reciepent' => env('GLOBAL_INBOUND_SENDER_PERMABLOCK_UNKNOWN_RECIEPENT', 5000), + ], 'cloudflare' => [ 'turnstile' => [ 'secret' => env('CLOUDFLARE_SECRET', null), diff --git a/config/services.php b/config/services.php index 4914ab2432f7..8ec5a41d7f8e 100644 --- a/config/services.php +++ b/config/services.php @@ -31,7 +31,7 @@ return [ ], 'brevo' => [ - 'key' => env('BREVO_SECRET', ''), + 'secret' => env('BREVO_SECRET', ''), ], 'postmark' => [ @@ -39,18 +39,26 @@ return [ ], 'postmark-outlook' => [ - 'token' => env('POSTMARK_OUTLOOK_SECRET',''), + 'token' => env('POSTMARK_OUTLOOK_SECRET', ''), 'from' => [ 'address' => env('POSTMARK_OUTLOOK_FROM_ADDRESS', '') ], ], - + 'microsoft' => [ 'client_id' => env('MICROSOFT_CLIENT_ID'), 'client_secret' => env('MICROSOFT_CLIENT_SECRET'), 'redirect' => env('MICROSOFT_REDIRECT_URI'), ], + 'mindee' => [ + 'api_key' => env('MINDEE_API_KEY'), + 'daily_limit' => env('MINDEE_DAILY_LIMIT', 100), + 'monthly_limit' => env('MINDEE_MONTHLY_LIMIT', 250), + 'account_daily_limit' => env('MINDEE_ACCOUNT_DAILY_LIMIT', 0), + 'account_monthly_limit' => env('MINDEE_ACCOUNT_MONTHLY_LIMIT', 0), + ], + 'apple' => [ 'client_id' => env('APPLE_CLIENT_ID'), 'client_secret' => env('APPLE_CLIENT_SECRET'), @@ -62,6 +70,7 @@ return [ 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('SES_REGION', 'us-east-1'), ], + 'sparkpost' => [ 'secret' => env('SPARKPOST_SECRET'), ], @@ -117,6 +126,7 @@ return [ 'key' => env('ZIP_TAX_KEY', false), ], ], + 'chorus' => [ 'client_id' => env('CHORUS_CLIENT_ID', false), 'secret' => env('CHORUS_SECRET', false), diff --git a/database/migrations/2023_12_10_110951_inbound_mail_parsing.php b/database/migrations/2023_12_10_110951_inbound_mail_parsing.php new file mode 100644 index 000000000000..7bbed2aab785 --- /dev/null +++ b/database/migrations/2023_12_10_110951_inbound_mail_parsing.php @@ -0,0 +1,32 @@ +boolean("expense_mailbox_active")->default(false); + $table->string("expense_mailbox")->nullable(); + $table->boolean("inbound_mailbox_allow_company_users")->default(false); + $table->boolean("inbound_mailbox_allow_vendors")->default(false); + $table->boolean("inbound_mailbox_allow_clients")->default(false); + $table->boolean("inbound_mailbox_allow_unknown")->default(false); + $table->text("inbound_mailbox_whitelist")->nullable(); + $table->text("inbound_mailbox_blacklist")->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/lang/en/texts.php b/lang/en/texts.php index 259172da8557..61fc8268af59 100644 --- a/lang/en/texts.php +++ b/lang/en/texts.php @@ -2492,6 +2492,8 @@ $lang = array( 'local_storage_required' => 'Error: local storage is not available.', 'your_password_reset_link' => 'Your Password Reset Link', 'subdomain_taken' => 'The subdomain is already in use', + 'expense_mailbox_taken' => 'The inbound mailbox is already in use', + 'expense_mailbox_invalid' => 'The inbound mailbox does not match the required schema', 'client_login' => 'Client Login', 'converted_amount' => 'Converted Amount', 'default' => 'Default', diff --git a/routes/api.php b/routes/api.php index 4dd8152d7207..a016a37659da 100644 --- a/routes/api.php +++ b/routes/api.php @@ -39,6 +39,9 @@ use App\Http\Controllers\CompanyController; use App\Http\Controllers\ExpenseController; use App\Http\Controllers\InvoiceController; use App\Http\Controllers\LicenseController; +use App\Http\Controllers\MailgunController; +use App\Http\Controllers\MigrationController; +use App\Http\Controllers\OneTimeTokenController; use App\Http\Controllers\PaymentController; use App\Http\Controllers\PreviewController; use App\Http\Controllers\ProductController; @@ -50,7 +53,6 @@ use App\Http\Controllers\ActivityController; use App\Http\Controllers\DocumentController; use App\Http\Controllers\PostMarkController; use App\Http\Controllers\TemplateController; -use App\Http\Controllers\MigrationController; use App\Http\Controllers\SchedulerController; use App\Http\Controllers\SubdomainController; use App\Http\Controllers\SystemLogController; @@ -66,14 +68,12 @@ use App\Http\Controllers\PaymentTermController; use App\PaymentDrivers\PayPalPPCPPaymentDriver; use App\Http\Controllers\EmailHistoryController; use App\Http\Controllers\GroupSettingController; -use App\Http\Controllers\OneTimeTokenController; use App\Http\Controllers\SubscriptionController; use App\Http\Controllers\Bank\NordigenController; use App\Http\Controllers\CompanyLedgerController; use App\Http\Controllers\PurchaseOrderController; use App\Http\Controllers\TaskSchedulerController; use App\Http\Controllers\CompanyGatewayController; -use App\Http\Controllers\MailgunWebhookController; use App\Http\Controllers\PaymentWebhookController; use App\Http\Controllers\RecurringQuoteController; use App\Http\Controllers\BankIntegrationController; @@ -444,8 +444,11 @@ 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::post('api/v1/brevo_webhook', [BrevoController::class, 'webhook'])->middleware('throttle:1000,1'); -Route::post('api/v1/mailgun_webhook', [MailgunWebhookController::class, 'webhook'])->middleware('throttle:1000,1'); +Route::post('api/v1/brevo_inbound_webhook', [BrevoController::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'); diff --git a/tests/Feature/CompanyTest.php b/tests/Feature/CompanyTest.php index b6ca9508ec2f..55b15b45ef67 100644 --- a/tests/Feature/CompanyTest.php +++ b/tests/Feature/CompanyTest.php @@ -50,6 +50,49 @@ class CompanyTest extends TestCase $this->makeTestData(); } + + public function testCompanyExpenseMailbox() + { + // Test valid email address + $company_update = [ + 'expense_mailbox' => 'valid@example.com', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->putJson('/api/v1/companies/'.$this->encodePrimaryKey($this->company->id), $company_update); + + $response->assertStatus(200); + $this->assertEquals('valid@example.com', $response->json('data.expense_mailbox')); + + // Test invalid email address + $company_update = [ + 'expense_mailbox' => 'invalid-email', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->putJson('/api/v1/companies/'.$this->encodePrimaryKey($this->company->id), $company_update); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['expense_mailbox']); + + // Test empty email address + $company_update = [ + 'expense_mailbox' => '', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->putJson('/api/v1/companies/'.$this->encodePrimaryKey($this->company->id), $company_update); + + $response->assertStatus(200); + $this->assertEmpty($response->json('data.expense_mailbox')); + } + public function testEnsureStrReplace() { $x = '**********'; @@ -216,4 +259,6 @@ class CompanyTest extends TestCase ])->delete('/api/v1/companies/'.$this->encodePrimaryKey($company->id)) ->assertStatus(200); } + + }