Merge pull request #9042 from paulwer/feature-inbound-email-expenses

Feature: inbound email expenses & mindee ocr
This commit is contained in:
David Bomba 2024-09-16 16:56:05 +10:00 committed by GitHub
commit cdacdc851e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 2887 additions and 218 deletions

View File

@ -11,6 +11,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Jobs\Brevo\ProcessBrevoInboundWebhook;
use App\Jobs\Brevo\ProcessBrevoWebhook; use App\Jobs\Brevo\ProcessBrevoWebhook;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -24,14 +25,14 @@ class BrevoController extends BaseController
} }
/** /**
* Process Postmark Webhook. * Process Brevo Webhook.
* *
* *
* @OA\Post( * @OA\Post(
* path="/api/v1/postmark_webhook", * path="/api/v1/brevo_webhook",
* operationId="postmarkWebhook", * operationId="brevoWebhook",
* tags={"postmark"}, * tags={"brevo"},
* summary="Processing webhooks from PostMark", * summary="Processing webhooks from Brevo",
* description="Adds an credit to the system", * description="Adds an credit to the system",
* @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"), * @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"), * @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
@ -59,12 +60,150 @@ class BrevoController extends BaseController
*/ */
public function webhook(Request $request) public function webhook(Request $request)
{ {
if ($request->has('token') && $request->get('token') == config('services.brevo.key')) { if ($request->has('token') && $request->get('token') == config('services.brevo.secret')) {
ProcessBrevoWebhook::dispatch($request->all())->delay(10); ProcessBrevoWebhook::dispatch($request->all())->delay(rand(2, 10));
return response()->json(['message' => 'Success'], 200); return response()->json(['message' => 'Success'], 200);
} }
return response()->json(['message' => 'Unauthorized'], 403); 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' => '<CADfEuNvumhUdqAUa0j6MxzVp0ooMYqdb_KZ7nZqHNAfdDqwWEQ@mail.gmail.com>',
* '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 <test@test.de>; 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 <max@mustermann.de>',
* 'Date' => 'Sat, 23 Mar 2024 18:18:20 +0100',
* 'Message-ID' => '<CADfEuNvumhUdqAUa0j6MxzVp0ooMYqdb_KZ7nZqHNAfdDqwWEQ@mail.gmail.com>',
* 'Subject' => 'TEST',
* 'To' => 'test@test.de',
* 'Content-Type' => 'multipart/mixed',
* ),
* 'SpamScore' => 2.8,
* 'ExtractedMarkdownMessage' => 'TEST',
* 'ExtractedMarkdownSignature' => NULL,
* 'RawHtmlBody' => '<div dir="ltr">TEST</div>',
* '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);
}
} }

View File

@ -497,7 +497,7 @@ class ExpenseController extends BaseController
$expenses = Expense::withTrashed()->find($request->ids); $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); $this->expense_repo->categorize($expenses, $request->category_id);
$expenses = collect([]); $expenses = collect([]);
} }
@ -573,7 +573,7 @@ class ExpenseController extends BaseController
*/ */
public function upload(UploadExpenseRequest $request, Expense $expense) public function upload(UploadExpenseRequest $request, Expense $expense)
{ {
if (! $this->checkFeature(Account::FEATURE_DOCUMENTS)) { if (!$this->checkFeature(Account::FEATURE_DOCUMENTS)) {
return $this->featureFailure(); return $this->featureFailure();
} }
@ -584,15 +584,60 @@ class ExpenseController extends BaseController
return $this->itemResponse($expense->fresh()); 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) public function edocument(EDocumentRequest $request)
{ {
$user = auth()->user(); $user = auth()->user();
foreach($request->file("documents") as $file) { foreach ($request->file("documents") as $file) {
ImportEDocument::dispatch($file->get(), $file->getClientOriginalName(), $user->company()); ImportEDocument::dispatch($file->get(), $file->getClientOriginalName(), $request->file("documents")->getMimeType(), $user->company());
} }
return response()->json(['message' => 'Processing....'], 200); return response()->json(['message' => 'Processing....'], 200);
} }
} }

View File

@ -0,0 +1,147 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;
use App\Models\Company;
use App\Libraries\MultiDB;
use Illuminate\Http\Request;
use App\Jobs\Mailgun\ProcessMailgunWebhook;
use App\Jobs\Mailgun\ProcessMailgunInboundWebhook;
/**
* Class MailgunController.
*/
class MailgunController extends BaseController
{
public function __construct()
{
}
/**
* Process Mailgun Webhook.
*
*
* @OA\Post(
* path="/api/v1/mailgun_webhook",
* operationId="mailgunWebhook",
* tags={"mailgun"},
* summary="Processing webhooks from Mailgun",
* description="Adds an credit to the system",
* @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Parameter(ref="#/components/parameters/include"),
* @OA\Response(
* response=200,
* description="Returns the saved credit object",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Credit"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
*
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function webhook(Request $request)
{
$input = $request->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);
}
}

View File

@ -1,42 +0,0 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;
use App\Jobs\Mailgun\ProcessMailgunWebhook;
use App\Jobs\PostMark\ProcessPostmarkWebhook;
use Illuminate\Http\Request;
/**
* Class MailgunWebhookController.
*/
class MailgunWebhookController extends BaseController
{
public function __construct()
{
}
public function webhook(Request $request)
{
$input = $request->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);
}
}

View File

@ -12,6 +12,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Jobs\PostMark\ProcessPostmarkWebhook; use App\Jobs\PostMark\ProcessPostmarkWebhook;
use App\Libraries\MultiDB;
use App\Services\InboundMail\InboundMail;
use App\Services\InboundMail\InboundMailEngine;
use App\Utils\TempFile;
use Illuminate\Support\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
/** /**
@ -60,11 +65,271 @@ class PostMarkController extends BaseController
public function webhook(Request $request) public function webhook(Request $request)
{ {
if ($request->header('X-API-SECURITY') && $request->header('X-API-SECURITY') == config('services.postmark.token')) { if ($request->header('X-API-SECURITY') && $request->header('X-API-SECURITY') == config('services.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' => 'Success'], 200);
} }
return response()->json(['message' => 'Unauthorized'], 403); 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 <max@mustermann.de>:
*
* > test
* >
*
* --
* test.de - Max Mustermann <https://test.de/>kontakt@test.de
* <mailto:kontakt@test.de>',
* 'HtmlBody' => '<div dir="ltr">wadwad</div><br><div class="gmail_quote"><div dir="ltr" class="gmail_attr">Am So., 24. März 2024 um 13:17 Uhr schrieb Max Mustermann &lt;<a href="mailto:max@mustermann.de">max@mustermann.de</a>&gt;:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"><div dir="ltr">test</div>
* </blockquote></div>
*
* <br>
* <font size="3"><a href="https://test.de/" target="_blank">test.de - Max Mustermann</a></font><div><a href="mailto:kontakt@test.de" style="font-size:medium" target="_blank">kontakt@test.de</a><br></div>',
* 'StrippedTextReply' => 'wadwad
*
* Am So., 24. März 2024 um 13:17 Uhr schrieb Max Mustermann <max@mustermann.de>:',
* 'Tag' => NULL,
* 'Headers' =>
* array (
* 0 =>
* array (
* 'Name' => 'Return-Path',
* 'Value' => '<max@mustermann.de>',
* ),
* 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' => '<CADfEuNsNFmNNCJDPjpS36amoLv2XEm41HmgYJT7Tj=R96PkxnA@mail.gmail.com>',
* ),
* 16 =>
* array (
* 'Name' => 'In-Reply-To',
* 'Value' => '<CADfEuNsNFmNNCJDPjpS36amoLv2XEm41HmgYJT7Tj=R96PkxnA@mail.gmail.com>',
* ),
* 17 =>
* array (
* 'Name' => 'Message-ID',
* 'Value' => '<CADfEuNvyCLsnp=CwJ3BF=-L6rn=o+DmUOPP6Cp4F-SO0p0hVwQ@mail.gmail.com>',
* ),
* ),
* '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);
}
} }

View File

@ -11,14 +11,15 @@
namespace App\Http\Requests\Company; namespace App\Http\Requests\Company;
use App\Utils\Ninja;
use App\Models\Company;
use App\Libraries\MultiDB;
use App\Http\Requests\Request; 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\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 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_host'] = 'sometimes|string|nullable';
$rules['smtp_port'] = 'sometimes|integer|nullable'; $rules['smtp_port'] = 'sometimes|integer|nullable';
$rules['smtp_encryption'] = 'sometimes|string'; $rules['smtp_encryption'] = 'sometimes|string';
@ -84,23 +87,27 @@ class StoreCompanyRequest extends Request
$input['portal_domain'] = rtrim(strtolower($input['portal_domain']), "/"); $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(); $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']); 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']); unset($input['smtp_password']);
} }
if(isset($input['smtp_port'])) { if (isset($input['smtp_port'])) {
$input['smtp_port'] = (int) $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; $input['smtp_verify_peer'] == 'true' ? true : false;
} }

View File

@ -14,10 +14,12 @@ namespace App\Http\Requests\Company;
use App\Utils\Ninja; use App\Utils\Ninja;
use App\Http\Requests\Request; use App\Http\Requests\Request;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Illuminate\Validation\Rule;
use App\DataMapper\CompanySettings; use App\DataMapper\CompanySettings;
use App\Http\ValidationRules\ValidSettingsRule; use App\Http\ValidationRules\ValidSettingsRule;
use App\Http\ValidationRules\EInvoice\ValidCompanyScheme;
use App\Http\ValidationRules\Company\ValidSubdomain; use App\Http\ValidationRules\Company\ValidSubdomain;
use App\Http\ValidationRules\Company\ValidExpenseMailbox;
use App\Http\ValidationRules\EInvoice\ValidCompanyScheme;
class UpdateCompanyRequest extends Request 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['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; return $rules;
} }
@ -87,31 +98,35 @@ class UpdateCompanyRequest extends Request
$input['portal_domain'] = rtrim(strtolower($input['portal_domain']), "/"); $input['portal_domain'] = rtrim(strtolower($input['portal_domain']), "/");
} }
if (isset($input['settings'])) { if (isset($input['expense_mailbox']) && Ninja::isHosted() && !($this->company->account->isPaid() && $this->company->account->plan == 'enterprise')) {
$input['settings'] = (array)$this->filterSaveableSettings($input['settings']); 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']); 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']); 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']); 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']); unset($input['smtp_password']);
} }
if(isset($input['smtp_port'])) { if (isset($input['smtp_port'])) {
$input['smtp_port'] = (int)$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; $input['smtp_verify_peer'] == 'true' ? true : false;
} }
@ -144,21 +159,21 @@ class UpdateCompanyRequest extends Request
} }
if (isset($settings['email_style_custom'])) { 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', '<?php', '@php', '@for','@class','</sc','<sc','html;base64', '@elseif', '@else', '@endunless', '@endisset', '@endempty', '@endauth', '@endguest', '@endproduction', '@endenv', '@hasSection', '@endhasSection', '@sectionMissing', '@endsectionMissing', '@endfor', '@endforeach', '@empty', '@endforelse', '@endwhile', '@continue', '@break', '@includeIf', '@includeWhen', '@includeUnless', '@includeFirst', '@component', '@endcomponent', '@endsection', '@yield', '@show', '@append', '@overwrite', '@stop', '@extends', '@endpush', '@stack', '@prepend', '@endprepend', '@slot', '@endslot', '@endphp', '@method', '@csrf', '@error', '@enderror', '@json', '@endverbatim', '@inject'], '', $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', '<?php', '@php', '@for', '@class', '</sc', '<sc', 'html;base64', '@elseif', '@else', '@endunless', '@endisset', '@endempty', '@endauth', '@endguest', '@endproduction', '@endenv', '@hasSection', '@endhasSection', '@sectionMissing', '@endsectionMissing', '@endfor', '@endforeach', '@empty', '@endforelse', '@endwhile', '@continue', '@break', '@includeIf', '@includeWhen', '@includeUnless', '@includeFirst', '@component', '@endcomponent', '@endsection', '@yield', '@show', '@append', '@overwrite', '@stop', '@extends', '@endpush', '@stack', '@prepend', '@endprepend', '@slot', '@endslot', '@endphp', '@method', '@csrf', '@error', '@enderror', '@json', '@endverbatim', '@inject'], '', $settings['email_style_custom']);
} }
if(isset($settings['company_logo']) && strlen($settings['company_logo']) > 2) { if (isset($settings['company_logo']) && strlen($settings['company_logo']) > 2) {
$settings['company_logo'] = $this->forceScheme($settings['company_logo']); $settings['company_logo'] = $this->forceScheme($settings['company_logo']);
} }
if (! $account->isFreeHostedClient()) { if (!$account->isFreeHostedClient()) {
return $settings; return $settings;
} }
$saveable_casts = CompanySettings::$free_plan_casts; $saveable_casts = CompanySettings::$free_plan_casts;
foreach ($settings as $key => $value) { foreach ($settings as $key => $value) {
if (! array_key_exists($key, $saveable_casts)) { if (!array_key_exists($key, $saveable_casts)) {
unset($settings->{$key}); unset($settings->{$key});
} }
} }
@ -170,7 +185,7 @@ class UpdateCompanyRequest extends Request
{ {
if (Ninja::isHosted()) { if (Ninja::isHosted()) {
$url = str_replace('http://', '', $url); $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, '/'); return rtrim($url, '/');

View File

@ -25,9 +25,9 @@ class EDocumentRequest extends Request
$rules = []; $rules = [];
if ($this->file('documents') && is_array($this->file('documents'))) { 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')) { } elseif ($this->file('documents')) {
$rules['documents'] = 'required|file|max:1000000|mimes:xml'; $rules['documents'] = 'required|file|max:1000000';
} }
return $rules; return $rules;
} }

View File

@ -0,0 +1,55 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\ValidationRules\Company;
use App\Libraries\MultiDB;
use App\Utils\Ninja;
use Illuminate\Contracts\Validation\Rule;
use Symfony\Component\Validator\Constraints\EmailValidator;
/**
* Class ValidCompanyQuantity.
*/
class ValidExpenseMailbox implements Rule
{
private array $endings = [];
public function __construct()
{
$this->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');
}
}

View File

@ -0,0 +1,244 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Jobs\Brevo;
use App\Utils\TempFile;
use App\Libraries\MultiDB;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Carbon;
use Brevo\Client\Configuration;
use Illuminate\Http\UploadedFile;
use Illuminate\Queue\SerializesModels;
use Brevo\Client\Api\InboundParsingApi;
use Illuminate\Queue\InteractsWithQueue;
use App\Services\InboundMail\InboundMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Services\InboundMail\InboundMailEngine;
use Illuminate\Queue\Middleware\WithoutOverlapping;
class ProcessBrevoInboundWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
private InboundMailEngine $engine;
/**
* Create a new job instance.
*
* IMPORTANT NOTICE: brevo strips old sended emails, therefore only current attachements are present
*
* $input consists of json/serialized-array:
*
* array (
* 'Uuid' =>
* array (
* 0 => 'd9f48d52-a344-42a4-9056-9733488d9fa3',
* ),
* 'Recipients' =>
* array (
* 0 => 'test@test.de',
* ),
* 'MessageId' => '<CADfEuNvumhUdqAUa0j6MxzVp0ooMYqdb_KZ7nZqHNAfdDqwWEQ@mail.gmail.com>',
* '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 <test@test.de>; 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 <max@mustermann.de>',
* 'Date' => 'Sat, 23 Mar 2024 18:18:20 +0100',
* 'Message-ID' => '<CADfEuNvumhUdqAUa0j6MxzVp0ooMYqdb_KZ7nZqHNAfdDqwWEQ@mail.gmail.com>',
* 'Subject' => 'TEST',
* 'To' => 'test@test.de',
* 'Content-Type' => 'multipart/mixed',
* ),
* 'SpamScore' => 2.8,
* 'ExtractedMarkdownMessage' => 'TEST',
* 'ExtractedMarkdownSignature' => NULL,
* 'RawHtmlBody' => '<div dir="ltr">TEST</div>',
* '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]);
}
}

View File

@ -426,7 +426,7 @@ class ProcessBrevoWebhook implements ShouldQueue
public function getRawMessage(string $message_id) 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)); $brevo = new TransactionalEmailsApi(null, Configuration::getDefaultConfiguration()->setApiKey('api-key', $brevo_secret));
$messageDetail = $brevo->getTransacEmailContent($message_id); $messageDetail = $brevo->getTransacEmailContent($message_id);

View File

@ -11,9 +11,11 @@
namespace App\Jobs\EDocument; namespace App\Jobs\EDocument;
use App\Models\Expense;
use App\Services\EDocument\Imports\ParseEDocument;
use App\Utils\TempFile;
use Exception; use Exception;
use App\Models\Company; use App\Models\Company;
use App\Models\Expense;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
@ -31,8 +33,9 @@ class ImportEDocument implements ShouldQueue
public $tries = 1; 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 public function handle(): Expense
{ {
switch (true) { $file = TempFile::UploadedFileFromRaw($this->file_content, $this->file_name, $this->file_mime_type);
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"): return (new ParseEDocument($file, $this->company))->run();
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");
}
} }
@ -64,7 +61,7 @@ class ImportEDocument implements ShouldQueue
public function failed($exception = null) public function failed($exception = null)
{ {
if ($exception) { if ($exception) {
nlog("EXCEPTION:: ImportEDocument:: ".$exception->getMessage()); nlog("EXCEPTION:: ImportEDocument:: " . $exception->getMessage());
} }
config(['queue.failed.driver' => null]); config(['queue.failed.driver' => null]);

View File

@ -0,0 +1,289 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Jobs\Mailgun;
use App\Models\Company;
use App\Utils\TempFile;
use App\Libraries\MultiDB;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Carbon;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use App\Services\InboundMail\InboundMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Services\InboundMail\InboundMailEngine;
class ProcessMailgunInboundWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
private InboundMailEngine $engine;
/**
* Create a new job instance.
* $input consists of 3 informations: sender/from|recipient/to|messageUrl
*/
public function __construct(private string $sender, private string $recipient, private string $message_url, private Company $company)
{
$this->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 <test@sender.example>",
* "In-Reply-To": "<CADfEuNuk6m=RUmo+R4K65Rfskox_LOTT+pnBwTnmA_gPaf1eUQ@mail.gmail.com>",
* "Message-Id": "<CADfEuNu6nBJYqNSJ-suey3a0FazJELYkNSO5JUGiiGs9hsFvGg@mail.gmail.com>",
* "Mime-Version": "1.0",
* "Received": "by mail-lj1-f175.google.com with SMTP id 38308e7fff4ca-2d4a901e284so12524521fa.1 for <test@domain.example>; Sun, 17 Mar 2024 22:34:47 -0700 (PDT)",
* "References": "<CADfEuNvN_DcTU99WY-7332iPmPuYW-CfvJfirQ6YY30e3y-XeA@mail.gmail.com> <CADfEuNupjxUivY++FnD7b1SHdNY6YZCA9b4iVW6xNbmic=B6Zw@mail.gmail.com> <CADfEuNuk6m=RUmo+R4K65Rfskox_LOTT+pnBwTnmA_gPaf1eUQ@mail.gmail.com>",
* "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 <test@sender.example>",
* "subject": "Fwd: TEST",
* "body-html": "<div dir=\"ltr\">TESTAGAIN<img src=\"cid:ii_ltwigc770\" alt=\"Unbenannt.png\" width=\"562\" height=\"408\"><br><br><div class=\"gmail_quote\"><div dir=\"ltr\" class=\"gmail_attr\">---------- Forwarded message ---------<br>Von: <strong class=\"gmail_sendername\" dir=\"auto\">Paul Werner</strong> <span dir=\"auto\">&lt;<a href=\"mailto:test@sender.example\">test@sender.example</a>&gt;</span><br>Date: Mo., 18. März 2024 um 06:30 Uhr<br>Subject: Fwd: TEST<br>To: &lt;<a href=\"mailto:test@domain.example\">test@domain.example</a>&gt;<br></div><br><br><div dir=\"ltr\">Hallöööö<br><br><div class=\"gmail_quote\"><div dir=\"ltr\" class=\"gmail_attr\">---------- Forwarded message ---------<br>Von: <strong class=\"gmail_sendername\" dir=\"auto\">Paul Werner</strong> <span dir=\"auto\">&lt;<a href=\"mailto:test@sender.example\" target=\"_blank\">test@sender.example</a>&gt;</span><br>Date: Mo., 18. März 2024 um 06:23 Uhr<br>Subject: Fwd: TEST<br>To: &lt;<a href=\"mailto:test@domain.example\" target=\"_blank\">test@domain.example</a>&gt;<br></div><br><br><div dir=\"ltr\">asjkdahwdaiohdawdawdawwwww!!!<br><br><div class=\"gmail_quote\"><div dir=\"ltr\" class=\"gmail_attr\">---------- Forwarded message ---------<br>Von: <strong class=\"gmail_sendername\" dir=\"auto\">Paul Werner</strong> <span dir=\"auto\">&lt;<a href=\"mailto:test@sender.example\" target=\"_blank\">test@sender.example</a>&gt;</span><br>Date: Mo., 18. März 2024 um 06:22 Uhr<br>Subject: TEST<br>To: &lt;<a href=\"mailto:test@domain.example\" target=\"_blank\">test@domain.example</a>&gt;<br></div><br><br><div dir=\"ltr\">TEST</div>\r\n</div></div>\r\n</div></div>\r\n</div></div>\r\n",
* "body-plain": "TESTAGAIN[image: Unbenannt.png]\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner <test@sender.example>\r\nDate: Mo., 18. März 2024 um 06:30 Uhr\r\nSubject: Fwd: TEST\r\nTo: <test@domain.example>\r\n\r\n\r\nHallöööö\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner <test@sender.example>\r\nDate: Mo., 18. März 2024 um 06:23 Uhr\r\nSubject: Fwd: TEST\r\nTo: <test@domain.example>\r\n\r\n\r\nasjkdahwdaiohdawdawdawwwww!!!\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner <test@sender.example>\r\nDate: Mo., 18. März 2024 um 06:22 Uhr\r\nSubject: TEST\r\nTo: <test@domain.example>\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": {
* "<ii_ltwigc770>": {
* "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 <undefined> (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 <test@domain.example>; 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",
* "<CADfEuNvN_DcTU99WY-7332iPmPuYW-CfvJfirQ6YY30e3y-XeA@mail.gmail.com> <CADfEuNupjxUivY++FnD7b1SHdNY6YZCA9b4iVW6xNbmic=B6Zw@mail.gmail.com> <CADfEuNuk6m=RUmo+R4K65Rfskox_LOTT+pnBwTnmA_gPaf1eUQ@mail.gmail.com>"
* ],
* [
* "In-Reply-To",
* "<CADfEuNuk6m=RUmo+R4K65Rfskox_LOTT+pnBwTnmA_gPaf1eUQ@mail.gmail.com>"
* ],
* [
* "From",
* "Paul Werner <test@sender.example>"
* ],
* [
* "Date",
* "Mon, 18 Mar 2024 06:34:09 +0100"
* ],
* [
* "Message-Id",
* "<CADfEuNu6nBJYqNSJ-suey3a0FazJELYkNSO5JUGiiGs9hsFvGg@mail.gmail.com>"
* ],
* [
* "Subject",
* "Fwd: TEST"
* ],
* [
* "To",
* "test@domain.example"
* ],
* [
* "Content-Type",
* "multipart/related; boundary=\"00000000000022bfbe0613e8b7f5\""
* ]
* ],
* "stripped-html": "<html><head></head><body><div dir=\"ltr\">TESTAGAIN<img src=\"cid:ii_ltwigc770\" alt=\"Unbenannt.png\" width=\"562\" height=\"408\"><br><br></div>\n</body></html>",
* "stripped-text": "TESTAGAIN[image: Unbenannt.png]\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner <test@sender.example>\r\nDate: Mo., 18. März 2024 um 06:30 Uhr\r\nSubject: Fwd: TEST\r\nTo: <test@domain.example>\r\n\r\n\r\nHallöööö\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner <test@sender.example>\r\nDate: Mo., 18. März 2024 um 06:23 Uhr\r\nSubject: Fwd: TEST\r\nTo: <test@domain.example>\r\n\r\n\r\nasjkdahwdaiohdawdawdawwwww!!!\r\n\r\n---------- Forwarded message ---------\r\nVon: Paul Werner <test@sender.example>\r\nDate: Mo., 18. März 2024 um 06:22 Uhr\r\nSubject: TEST\r\nTo: <test@domain.example>\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);
}
}

View File

@ -73,6 +73,8 @@ class MultiDB
'socket', 'socket',
]; ];
private static $protected_expense_mailboxes = [];
/** /**
* @return array * @return array
*/ */
@ -84,7 +86,7 @@ class MultiDB
public static function checkDomainAvailable($subdomain): bool 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; return Company::whereSubdomain($subdomain)->count() == 0;
} }
@ -107,9 +109,35 @@ class MultiDB
return true; 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 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(); return User::where(['email' => $email])->withTrashed()->exists();
} // true >= 1 emails found / false -> == emails found } // true >= 1 emails found / false -> == emails found
@ -170,7 +198,7 @@ class MultiDB
*/ */
public static function hasUser(array $data): ?User 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(); return User::where($data)->withTrashed()->first();
} }
@ -194,7 +222,7 @@ class MultiDB
*/ */
public static function hasContact(string $email): ?ClientContact 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(); return ClientContact::where('email', $email)->withTrashed()->first();
} }
@ -221,7 +249,7 @@ class MultiDB
*/ */
public static function findContact(array $search): ?ClientContact 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(); return ClientContact::where($search)->first();
} }
@ -485,7 +513,7 @@ class MultiDB
public static function findAndSetDbByDomain($query_array) 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(); return Company::where($query_array)->first();
} }
@ -504,9 +532,30 @@ class MultiDB
return false; 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) 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(); return PaymentHash::with('fee_invoice')->where('hash', $hash)->first();
} }
@ -527,7 +576,7 @@ class MultiDB
public static function findAndSetDbByInvitation($entity, $invitation_key) 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'); $current_db = config('database.default');
foreach (self::$dbs as $db) { foreach (self::$dbs as $db) {
@ -549,13 +598,13 @@ class MultiDB
*/ */
public static function hasPhoneNumber(string $phone): bool 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(); return Account::where('account_sms_verification_number', $phone)->where('account_sms_verified', true)->exists();
} }
$current_db = config('database.default'); $current_db = config('database.default');
if(SMSNumbers::hasNumber($phone)) { // @phpstan-ignore-line if (SMSNumbers::hasNumber($phone)) { // @phpstan-ignore-line
return true; return true;
} }
@ -583,8 +632,26 @@ class MultiDB
$string = ''; $string = '';
$vowels = ['a', 'e', 'i', 'o', 'u', 'y']; $vowels = ['a', 'e', 'i', 'o', 'u', 'y'];
$consonants = [ $consonants = [
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'b',
'n', 'p', 'r', 's', 't', 'v', 'w', 'x', 'y', 'z', 'c',
'd',
'f',
'g',
'h',
'j',
'k',
'l',
'm',
'n',
'p',
'r',
's',
't',
'v',
'w',
'x',
'y',
'z',
]; ];
$max = $length / 2; $max = $length / 2;
@ -592,7 +659,7 @@ class MultiDB
$string .= $consonants[rand(0, 19)]; $string .= $consonants[rand(0, 19)];
$string .= $vowels[rand(0, 5)]; $string .= $vowels[rand(0, 5)];
} }
} while (! self::checkDomainAvailable($string)); } while (!self::checkDomainAvailable($string));
self::setDb($current_db); self::setDb($current_db);

View File

@ -112,6 +112,14 @@ use Laracasts\Presenter\PresentableTrait;
* @property int $convert_expense_currency * @property int $convert_expense_currency
* @property int $notify_vendor_when_paid * @property int $notify_vendor_when_paid
* @property int $invoice_task_hours * @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 int $deleted_at
* @property string|null $smtp_username * @property string|null $smtp_username
* @property string|null $smtp_password * @property string|null $smtp_password
@ -362,6 +370,14 @@ class Company extends BaseModel
'calculate_taxes', 'calculate_taxes',
'tax_data', 'tax_data',
'e_invoice_certificate_passphrase', '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_host',
'smtp_port', 'smtp_port',
'smtp_encryption', 'smtp_encryption',
@ -861,7 +877,7 @@ class Company extends BaseModel
return $this->portal_domain; 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'); return config('ninja.app_url');
@ -879,7 +895,7 @@ class Company extends BaseModel
public function file_path(): string public function file_path(): string
{ {
return $this->company_key.'/'; return $this->company_key . '/';
} }
public function rBits() public function rBits()
@ -967,7 +983,7 @@ class Company extends BaseModel
public function getInvoiceCert() public function getInvoiceCert()
{ {
if($this->e_invoice_certificate) { if ($this->e_invoice_certificate) {
return base64_decode($this->e_invoice_certificate); return base64_decode($this->e_invoice_certificate);
} }

View File

@ -113,6 +113,8 @@ class SystemLog extends Model
public const EVENT_USER = 61; public const EVENT_USER = 61;
public const EVENT_INBOUND_MAIL_BLOCKED = 62;
/*Type IDs*/ /*Type IDs*/
public const TYPE_PAYPAL = 300; public const TYPE_PAYPAL = 300;

View File

@ -181,7 +181,7 @@ class Vendor extends BaseModel
/** @var \Illuminate\Support\Collection<\App\Models\Currency> */ /** @var \Illuminate\Support\Collection<\App\Models\Currency> */
$currencies = app('currencies'); $currencies = app('currencies');
if (! $this->currency_id) { if (!$this->currency_id) {
return $this->company->currency(); return $this->company->currency();
} }
@ -217,14 +217,14 @@ class Vendor extends BaseModel
{ {
$defaults = []; $defaults = [];
if (! (array_key_exists('terms', $data) && strlen($data['terms']) > 1)) { if (!(array_key_exists('terms', $data) && strlen($data['terms']) > 1)) {
$defaults['terms'] = $this->getSetting($entity_name.'_terms'); $defaults['terms'] = $this->getSetting($entity_name . '_terms');
} elseif (array_key_exists('terms', $data)) { } elseif (array_key_exists('terms', $data)) {
$defaults['terms'] = $data['terms']; $defaults['terms'] = $data['terms'];
} }
if (! (array_key_exists('footer', $data) && strlen($data['footer']) > 1)) { if (!(array_key_exists('footer', $data) && strlen($data['footer']) > 1)) {
$defaults['footer'] = $this->getSetting($entity_name.'_footer'); $defaults['footer'] = $this->getSetting($entity_name . '_footer');
} elseif (array_key_exists('footer', $data)) { } elseif (array_key_exists('footer', $data)) {
$defaults['footer'] = $data['footer']; $defaults['footer'] = $data['footer'];
} }
@ -262,7 +262,7 @@ class Vendor extends BaseModel
{ {
$contact_key = $invitation->contact->contact_key; $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 public function locale(): string
@ -287,7 +287,7 @@ class Vendor extends BaseModel
public function backup_path(): string 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() public function service()

View File

@ -126,18 +126,18 @@ class AppServiceProvider extends ServiceProvider
new Dsn( new Dsn(
'brevo+api', 'brevo+api',
'default', '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 **/ // @phpstan-ignore /** @phpstan-ignore-next-line **/
Mailer::setSymfonyTransport( Mailer::setSymfonyTransport(
(new BrevoTransportFactory())->create( (new BrevoTransportFactory())->create(
new Dsn( new Dsn(
'brevo+api', 'brevo+api',
'default', 'default',
$brevo_key $brevo_secret
) )
) )
); );

View File

@ -0,0 +1,188 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Imports;
use App\Factory\ExpenseFactory;
use App\Factory\VendorContactFactory;
use App\Factory\VendorFactory;
use App\Models\Company;
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 Cache;
use Exception;
use Mindee\Client;
use Mindee\Product\Invoice\InvoiceV4;
use Illuminate\Http\UploadedFile;
class MindeeEDocument extends AbstractService
{
use SavesDocuments;
/**
* @throws Exception
*/
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
}
/**
* @throws Exception
*/
public function run(): Expense
{
$api_key = config('services.mindee.api_key');
if (!$api_key)
throw new Exception('Mindee API key not configured');
$this->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);
}
}

View File

@ -0,0 +1,81 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\EDocument\Imports;
use App\Models\Company;
use App\Models\Expense;
use App\Services\AbstractService;
use App\Utils\Ninja;
use Exception;
use Illuminate\Http\UploadedFile;
class ParseEDocument extends AbstractService
{
/**
* @throws Exception
*/
public function __construct(private UploadedFile $file, private Company $company)
{
}
/**
* Execute the service.
* the service will parse the file with all available libraries of the system and will return an expense, when possible
*
* @developer the function should be implemented with local first aproach to save costs of external providers (like mindee ocr)
*
* @return Expense
* @throws \Exception
*/
public function run(): Expense
{
/** @var \App\Models\Account $account */
$account = $this->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);
}
}

View File

@ -11,29 +11,33 @@
namespace App\Services\EDocument\Imports; 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\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\Services\AbstractService;
use App\Utils\TempFile;
use App\Utils\Traits\SavesDocuments;
use Exception;
use App\Models\Company;
use horstoeko\zugferd\ZugferdDocumentReader; use horstoeko\zugferd\ZugferdDocumentReader;
use horstoeko\zugferdvisualizer\ZugferdVisualizer; use horstoeko\zugferdvisualizer\ZugferdVisualizer;
use horstoeko\zugferdvisualizer\renderer\ZugferdVisualizerLaravelRenderer; use horstoeko\zugferdvisualizer\renderer\ZugferdVisualizerLaravelRenderer;
use Illuminate\Http\UploadedFile;
class ZugferdEDocument extends AbstractService class ZugferdEDocument extends AbstractService
{ {
use SavesDocuments;
public ZugferdDocumentReader|string $document; public ZugferdDocumentReader|string $document;
/** /**
* @throws Exception * @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 # 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 */ /** @var \App\Models\User $user */
$user = $this->company->owner(); $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->getDocumentInformation($documentno, $documenttypecode, $documentdate, $invoiceCurrency, $taxCurrency, $documentname, $documentlanguage, $effectiveSpecifiedPeriod);
$this->document->getDocumentSummation($grandTotalAmount, $duePayableAmount, $lineTotalAmount, $chargeTotalAmount, $allowanceTotalAmount, $taxBasisTotalAmount, $taxTotalAmount, $roundingAmount, $totalPrepaidAmount); $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->currency_id = Currency::whereCode($invoiceCurrency)->first()->id ?? $this->company->settings->currency_id;
$expense->save(); $expense->save();
$origin_file = TempFile::UploadedFileFromRaw($this->tempdocument, $this->documentname, "application/xml"); $documents = [$this->file];
(new UploadFile($origin_file, UploadFile::DOCUMENT, $user, $expense->company, $expense, null, false))->handle(); if ($this->file->getExtension() == "xml")
$uploaded_file = TempFile::UploadedFileFromRaw($visualizer->renderPdf(), $documentno."_visualiser.pdf", "application/pdf"); array_push($documents, TempFile::UploadedFileFromRaw($visualizer->renderPdf(), $documentno . "_visualiser.pdf", "application/pdf"));
(new UploadFile($uploaded_file, UploadFile::DOCUMENT, $user, $expense->company, $expense, null, false))->handle(); $this->saveDocuments($documents, $expense);
$expense->save(); $expense->save();
if ($taxCurrency && $taxCurrency != $invoiceCurrency) { if ($taxCurrency && $taxCurrency != $invoiceCurrency) {
$expense->private_notes = ctrans("texts.tax_currency_mismatch"); $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->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->getDocumentSellerAddress($address_1, $address_2, $address_3, $postcode, $city, $country, $subdivision);
$this->document->getDocumentSellerTaxRegistration($taxtype); $this->document->getDocumentSellerTaxRegistration($taxtype);
$taxid = null; $taxid = null;
if (array_key_exists("VA", $taxtype)) { if (array_key_exists("VA", $taxtype)) {
$taxid = $taxtype["VA"]; $taxid = $taxtype["VA"];
} }
$vendor = Vendor::where('vat_number', $taxid)->first();
if (!empty($vendor)) { $vendor = Vendor::query()
// Vendor found ->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; $expense->vendor_id = $vendor->id;
} else { } else {
$vendor = VendorFactory::create($this->company->id, $user->id); $vendor = VendorFactory::create($this->company->id, $user->id);
@ -112,7 +129,12 @@ class ZugferdEDocument extends AbstractService
$vendor->address2 = $address_2; $vendor->address2 = $address_2;
$vendor->city = $city; $vendor->city = $city;
$vendor->postal_code = $postcode; $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(); $vendor->save();
$expense->vendor_id = $vendor->id; $expense->vendor_id = $vendor->id;
@ -121,7 +143,7 @@ class ZugferdEDocument extends AbstractService
} else { } else {
// The document exists as an expense // The document exists as an expense
// Handle accordingly // 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->private_notes = $expense->private_notes . ctrans("texts.edocument_import_already_exists", ["date" => time()]);
} }
$expense->save(); $expense->save();

View File

@ -0,0 +1,42 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\InboundMail;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
/**
* InboundMail.
*/
class InboundMail
{
public string $to;
public string $from;
public ?string $subject = null;
public ?string $body = null;
public ?UploadedFile $body_document = null;
public string $text_body;
/** @var array $documents */
public array $documents = [];
public ?Carbon $date = null;
public function __construct()
{
}
}

View File

@ -0,0 +1,318 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\InboundMail;
use App\Factory\ExpenseFactory;
use App\Jobs\Util\SystemLogger;
use App\Libraries\MultiDB;
use App\Models\ClientContact;
use App\Models\Company;
use App\Models\SystemLog;
use App\Models\VendorContact;
use App\Services\EDocument\Imports\ParseEDocument;
use App\Services\InboundMail\InboundMail;
use App\Utils\Ninja;
use App\Utils\TempFile;
use App\Utils\Traits\GeneratesCounter;
use App\Utils\Traits\SavesDocuments;
use App\Utils\Traits\MakesHash;
use Cache;
use Illuminate\Queue\SerializesModels;
class InboundMailEngine
{
use SerializesModels, MakesHash;
use GeneratesCounter, SavesDocuments;
private array $globalBlacklist;
private array $globalWhitelist;
public function __construct(private Company $company)
{
$this->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();
}
}

View File

@ -203,14 +203,22 @@ class CompanyTransformer extends EntityTransformer
'has_e_invoice_certificate_passphrase' => $company->e_invoice_certificate_passphrase ? true : false, 'has_e_invoice_certificate_passphrase' => $company->e_invoice_certificate_passphrase ? true : false,
'invoice_task_project_header' => (bool) $company->invoice_task_project_header, 'invoice_task_project_header' => (bool) $company->invoice_task_project_header,
'invoice_task_item_description' => (bool) $company->invoice_task_item_description, 'invoice_task_item_description' => (bool) $company->invoice_task_item_description,
'origin_tax_data' => $company->origin_tax_data ?: new \stdClass(), 'origin_tax_data' => $company->origin_tax_data ?: new \stdClass,
'smtp_host' => (string)$company->smtp_host ?? '', 'expense_mailbox' => (string) $company->expense_mailbox,
'smtp_port' => (int)$company->smtp_port ?? 25, 'expense_mailbox_active' => (bool) $company->expense_mailbox_active,
'smtp_encryption' => (string)$company->smtp_encryption ?? 'tls', '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_username' => $company->smtp_username ? '********' : '',
'smtp_password' => $company->smtp_password ? '********' : '', 'smtp_password' => $company->smtp_password ? '********' : '',
'smtp_local_domain' => (string)$company->smtp_local_domain ?? '', 'smtp_local_domain' => (string) $company->smtp_local_domain ?? '',
'smtp_verify_peer' => (bool)$company->smtp_verify_peer, 'smtp_verify_peer' => (bool) $company->smtp_verify_peer,
'e_invoice' => $company->e_invoice ?: new \stdClass(), 'e_invoice' => $company->e_invoice ?: new \stdClass(),
'has_quickbooks_token' => $company->quickbooks ? true : false, 'has_quickbooks_token' => $company->quickbooks ? true : false,
'is_quickbooks_token_active' => $company->quickbooks?->accessTokenKey ?? false, 'is_quickbooks_token_active' => $company->quickbooks?->accessTokenKey ?? false,

View File

@ -13,12 +13,13 @@ namespace App\Utils;
use Illuminate\Http\File; use Illuminate\Http\File;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
class TempFile class TempFile
{ {
public static function path($url): string 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); copy($url, $temp_path);
return $temp_path; return $temp_path;
@ -27,17 +28,50 @@ class TempFile
/* Downloads a file to temp storage and returns the path - used for mailers */ /* Downloads a file to temp storage and returns the path - used for mailers */
public static function filePath($data, $filename): string 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); mkdir($dir_hash);
$file_path = $dir_hash.'/'.$filename; $file_path = $dir_hash . '/' . $filename;
file_put_contents($file_path, $data); file_put_contents($file_path, $data);
return $file_path; 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 public static function UploadedFileFromRaw(string $fileData, string|null $fileName = null, string|null $mimeType = null): UploadedFile
{ {
// Create temp file and get its absolute path // Create temp file and get its absolute path

View File

@ -76,7 +76,9 @@
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/fractal": "^0.20.0", "league/fractal": "^0.20.0",
"livewire/livewire": "^3", "livewire/livewire": "^3",
"mailgun/mailgun-php": "^3.6",
"microsoft/microsoft-graph": "^1.69", "microsoft/microsoft-graph": "^1.69",
"mindee/mindee": "^1.8",
"mollie/mollie-api-php": "^2.36", "mollie/mollie-api-php": "^2.36",
"nelexa/zip": "^4.0", "nelexa/zip": "^4.0",
"nordigen/nordigen-php": "^1.1", "nordigen/nordigen-php": "^1.1",

712
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "3d0c51f983bca2b17f021bdfbf781245", "content-hash": "a2e8043b8cfa0f971360c3bc8d36fa2c",
"packages": [ "packages": [
{ {
"name": "adrienrn/php-mimetyper", "name": "adrienrn/php-mimetyper",
@ -852,16 +852,16 @@
}, },
{ {
"name": "btcpayserver/btcpayserver-greenfield-php", "name": "btcpayserver/btcpayserver-greenfield-php",
"version": "v2.6.0", "version": "v2.7.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/btcpayserver/btcpayserver-greenfield-php.git", "url": "https://github.com/btcpayserver/btcpayserver-greenfield-php.git",
"reference": "c115b0415719b9fe6e35d5df5f291646d4af2240" "reference": "5e2ba7e3f585fc8e6dc068e22a0efbfdacd9c992"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/btcpayserver/btcpayserver-greenfield-php/zipball/c115b0415719b9fe6e35d5df5f291646d4af2240", "url": "https://api.github.com/repos/btcpayserver/btcpayserver-greenfield-php/zipball/5e2ba7e3f585fc8e6dc068e22a0efbfdacd9c992",
"reference": "c115b0415719b9fe6e35d5df5f291646d4af2240", "reference": "5e2ba7e3f585fc8e6dc068e22a0efbfdacd9c992",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -900,9 +900,9 @@
"description": "BTCPay Server Greenfield API PHP client library.", "description": "BTCPay Server Greenfield API PHP client library.",
"support": { "support": {
"issues": "https://github.com/btcpayserver/btcpayserver-greenfield-php/issues", "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", "name": "carbonphp/carbon-doctrine-types",
@ -1041,6 +1041,72 @@
}, },
"time": "2024-08-29T07:34:57+00:00" "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", "name": "composer/ca-bundle",
"version": "1.5.1", "version": "1.5.1",
@ -3489,16 +3555,16 @@
}, },
{ {
"name": "horstoeko/zugferd", "name": "horstoeko/zugferd",
"version": "v1.0.60", "version": "v1.0.61",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/horstoeko/zugferd.git", "url": "https://github.com/horstoeko/zugferd.git",
"reference": "10c2296f35060e2c748d8951c97fcb8496c843de" "reference": "e3c0d2b3c2aa84716bfbe235a15870f3798246e1"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/horstoeko/zugferd/zipball/10c2296f35060e2c748d8951c97fcb8496c843de", "url": "https://api.github.com/repos/horstoeko/zugferd/zipball/e3c0d2b3c2aa84716bfbe235a15870f3798246e1",
"reference": "10c2296f35060e2c748d8951c97fcb8496c843de", "reference": "e3c0d2b3c2aa84716bfbe235a15870f3798246e1",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -3558,9 +3624,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/horstoeko/zugferd/issues", "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", "name": "horstoeko/zugferdvisualizer",
@ -3938,12 +4004,12 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/invoiceninja/einvoice.git", "url": "https://github.com/invoiceninja/einvoice.git",
"reference": "1ec178ec134981629932aae12677e947ee3df091" "reference": "fe13a98c970bc604d4467f34476109745d2602ca"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/invoiceninja/einvoice/zipball/1ec178ec134981629932aae12677e947ee3df091", "url": "https://api.github.com/repos/invoiceninja/einvoice/zipball/fe13a98c970bc604d4467f34476109745d2602ca",
"reference": "1ec178ec134981629932aae12677e947ee3df091", "reference": "fe13a98c970bc604d4467f34476109745d2602ca",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -3985,7 +4051,7 @@
"source": "https://github.com/invoiceninja/einvoice/tree/main", "source": "https://github.com/invoiceninja/einvoice/tree/main",
"issues": "https://github.com/invoiceninja/einvoice/issues" "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", "name": "invoiceninja/inspector",
@ -4490,16 +4556,16 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v11.22.0", "version": "v11.23.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "868c75beacc47d0f361b919bbc155c0b619bf3d5" "reference": "16b31ab0e1dad5cb2ed6dcc1818c02f02fc48453"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/868c75beacc47d0f361b919bbc155c0b619bf3d5", "url": "https://api.github.com/repos/laravel/framework/zipball/16b31ab0e1dad5cb2ed6dcc1818c02f02fc48453",
"reference": "868c75beacc47d0f361b919bbc155c0b619bf3d5", "reference": "16b31ab0e1dad5cb2ed6dcc1818c02f02fc48453",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -4561,6 +4627,7 @@
"illuminate/bus": "self.version", "illuminate/bus": "self.version",
"illuminate/cache": "self.version", "illuminate/cache": "self.version",
"illuminate/collections": "self.version", "illuminate/collections": "self.version",
"illuminate/concurrency": "self.version",
"illuminate/conditionable": "self.version", "illuminate/conditionable": "self.version",
"illuminate/config": "self.version", "illuminate/config": "self.version",
"illuminate/console": "self.version", "illuminate/console": "self.version",
@ -4603,7 +4670,7 @@
"league/flysystem-sftp-v3": "^3.0", "league/flysystem-sftp-v3": "^3.0",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nyholm/psr7": "^1.2", "nyholm/psr7": "^1.2",
"orchestra/testbench-core": "^9.1.5", "orchestra/testbench-core": "^9.4.0",
"pda/pheanstalk": "^5.0", "pda/pheanstalk": "^5.0",
"phpstan/phpstan": "^1.11.5", "phpstan/phpstan": "^1.11.5",
"phpunit/phpunit": "^10.5|^11.0", "phpunit/phpunit": "^10.5|^11.0",
@ -4661,6 +4728,7 @@
"src/Illuminate/Events/functions.php", "src/Illuminate/Events/functions.php",
"src/Illuminate/Filesystem/functions.php", "src/Illuminate/Filesystem/functions.php",
"src/Illuminate/Foundation/helpers.php", "src/Illuminate/Foundation/helpers.php",
"src/Illuminate/Log/functions.php",
"src/Illuminate/Support/helpers.php" "src/Illuminate/Support/helpers.php"
], ],
"psr-4": { "psr-4": {
@ -6104,6 +6172,67 @@
], ],
"time": "2023-06-21T14:59:35+00:00" "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", "name": "markbaker/complex",
"version": "3.0.2", "version": "3.0.2",
@ -6331,17 +6460,72 @@
"time": "2024-01-15T18:49:30+00:00" "time": "2024-01-15T18:49:30+00:00"
}, },
{ {
"name": "mollie/mollie-api-php", "name": "mindee/mindee",
"version": "v2.71.0", "version": "v1.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/mollie/mollie-api-php.git", "url": "https://github.com/mindee/mindee-api-php.git",
"reference": "dff324f0621ff134fbefffa42ee511833a58578f" "reference": "40865a03e34bb2416b32e5e1dd4937020e7bcc27"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/mollie/mollie-api-php/zipball/dff324f0621ff134fbefffa42ee511833a58578f", "url": "https://api.github.com/repos/mindee/mindee-api-php/zipball/40865a03e34bb2416b32e5e1dd4937020e7bcc27",
"reference": "dff324f0621ff134fbefffa42ee511833a58578f", "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": "" "shasum": ""
}, },
"require": { "require": {
@ -6418,9 +6602,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/mollie/mollie-api-php/issues", "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", "name": "monolog/monolog",
@ -7352,27 +7536,27 @@
}, },
{ {
"name": "nwidart/laravel-modules", "name": "nwidart/laravel-modules",
"version": "v11.0.11", "version": "v11.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nWidart/laravel-modules.git", "url": "https://github.com/nWidart/laravel-modules.git",
"reference": "9d50adcbf8d11c9ec01e48a5b7adbf320653185c" "reference": "2ae13812f055a85d7063e90366884cd327877821"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nWidart/laravel-modules/zipball/9d50adcbf8d11c9ec01e48a5b7adbf320653185c", "url": "https://api.github.com/repos/nWidart/laravel-modules/zipball/2ae13812f055a85d7063e90366884cd327877821",
"reference": "9d50adcbf8d11c9ec01e48a5b7adbf320653185c", "reference": "2ae13812f055a85d7063e90366884cd327877821",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"laravel/pint": "^1.16",
"php": ">=8.2", "php": ">=8.2",
"wikimedia/composer-merge-plugin": "^2.1" "wikimedia/composer-merge-plugin": "^2.1"
}, },
"require-dev": { "require-dev": {
"friendsofphp/php-cs-fixer": "^v3.52", "friendsofphp/php-cs-fixer": "^v3.52",
"laravel/framework": "^v11.0", "laravel/framework": "^v11.0",
"laravel/pint": "^1.16",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"orchestra/testbench": "^v9.0", "orchestra/testbench": "^v9.0",
"phpstan/phpstan": "^1.4", "phpstan/phpstan": "^1.4",
@ -7423,7 +7607,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/nWidart/laravel-modules/issues", "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": [ "funding": [
{ {
@ -7435,7 +7619,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-06-16T16:26:29+00:00" "time": "2024-09-13T19:24:08+00:00"
}, },
{ {
"name": "nyholm/psr7", "name": "nyholm/psr7",
@ -7808,6 +7992,388 @@
}, },
"time": "2024-04-08T12:52:34+00:00" "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", "name": "php-jsonpointer/php-jsonpointer",
"version": "v3.0.2", "version": "v3.0.2",
@ -8850,16 +9416,16 @@
}, },
{ {
"name": "psr/log", "name": "psr/log",
"version": "3.0.1", "version": "3.0.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/php-fig/log.git", "url": "https://github.com/php-fig/log.git",
"reference": "79dff0b268932c640297f5208d6298f71855c03e" "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e", "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "79dff0b268932c640297f5208d6298f71855c03e", "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -8894,9 +9460,9 @@
"psr-3" "psr-3"
], ],
"support": { "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", "name": "psr/simple-cache",
@ -10272,6 +10838,66 @@
}, },
"time": "2024-07-12T02:43:55+00:00" "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", "name": "sprain/swiss-qr-bill",
"version": "v4.14", "version": "v4.14",

View File

@ -82,11 +82,11 @@ return [
'username' => 'user@example.com', 'username' => 'user@example.com',
'clientname' => 'client@example.com', 'clientname' => 'client@example.com',
'password' => 'password', 'password' => 'password',
'gocardless' => env('GOCARDLESS_KEYS',''), 'gocardless' => env('GOCARDLESS_KEYS', ''),
'square' => env('SQUARE_KEYS',''), 'square' => env('SQUARE_KEYS', ''),
'eway' => env('EWAY_KEYS',''), 'eway' => env('EWAY_KEYS', ''),
'mollie', env('MOLLIE_KEYS',''), 'mollie' => env('MOLLIE_KEYS', ''),
'paytrace' => env('PAYTRACE_KEYS',''), 'paytrace' => env('PAYTRACE_KEYS', ''),
'stripe' => env('STRIPE_KEYS', ''), 'stripe' => env('STRIPE_KEYS', ''),
'paypal' => env('PAYPAL_KEYS', ''), 'paypal' => env('PAYPAL_KEYS', ''),
'ppcp' => env('PPCP_KEYS', ''), 'ppcp' => env('PPCP_KEYS', ''),
@ -98,7 +98,6 @@ return [
'test_email' => env('TEST_EMAIL', 'test@example.com'), 'test_email' => env('TEST_EMAIL', 'test@example.com'),
'wepay' => env('WEPAY_KEYS', ''), 'wepay' => env('WEPAY_KEYS', ''),
'braintree' => env('BRAINTREE_KEYS', ''), 'braintree' => env('BRAINTREE_KEYS', ''),
'mollie' => env('MOLLIE_KEYS', ''),
], ],
'contact' => [ 'contact' => [
'email' => env('MAIL_FROM_ADDRESS'), 'email' => env('MAIL_FROM_ADDRESS'),
@ -232,6 +231,17 @@ return [
'client_id' => env('PAYPAL_CLIENT_ID', null), 'client_id' => env('PAYPAL_CLIENT_ID', null),
'webhook_id' => env('PAYPAL_WEBHOOK_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' => [ 'cloudflare' => [
'turnstile' => [ 'turnstile' => [
'secret' => env('CLOUDFLARE_SECRET', null), 'secret' => env('CLOUDFLARE_SECRET', null),

View File

@ -31,7 +31,7 @@ return [
], ],
'brevo' => [ 'brevo' => [
'key' => env('BREVO_SECRET', ''), 'secret' => env('BREVO_SECRET', ''),
], ],
'postmark' => [ 'postmark' => [
@ -39,7 +39,7 @@ return [
], ],
'postmark-outlook' => [ 'postmark-outlook' => [
'token' => env('POSTMARK_OUTLOOK_SECRET',''), 'token' => env('POSTMARK_OUTLOOK_SECRET', ''),
'from' => [ 'from' => [
'address' => env('POSTMARK_OUTLOOK_FROM_ADDRESS', '') 'address' => env('POSTMARK_OUTLOOK_FROM_ADDRESS', '')
], ],
@ -51,6 +51,14 @@ return [
'redirect' => env('MICROSOFT_REDIRECT_URI'), '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' => [ 'apple' => [
'client_id' => env('APPLE_CLIENT_ID'), 'client_id' => env('APPLE_CLIENT_ID'),
'client_secret' => env('APPLE_CLIENT_SECRET'), 'client_secret' => env('APPLE_CLIENT_SECRET'),
@ -62,6 +70,7 @@ return [
'secret' => env('AWS_SECRET_ACCESS_KEY'), 'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('SES_REGION', 'us-east-1'), 'region' => env('SES_REGION', 'us-east-1'),
], ],
'sparkpost' => [ 'sparkpost' => [
'secret' => env('SPARKPOST_SECRET'), 'secret' => env('SPARKPOST_SECRET'),
], ],
@ -117,6 +126,7 @@ return [
'key' => env('ZIP_TAX_KEY', false), 'key' => env('ZIP_TAX_KEY', false),
], ],
], ],
'chorus' => [ 'chorus' => [
'client_id' => env('CHORUS_CLIENT_ID', false), 'client_id' => env('CHORUS_CLIENT_ID', false),
'secret' => env('CHORUS_SECRET', false), 'secret' => env('CHORUS_SECRET', false),

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('companies', function (Blueprint $table) {
$table->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
{
//
}
};

View File

@ -2492,6 +2492,8 @@ $lang = array(
'local_storage_required' => 'Error: local storage is not available.', 'local_storage_required' => 'Error: local storage is not available.',
'your_password_reset_link' => 'Your Password Reset Link', 'your_password_reset_link' => 'Your Password Reset Link',
'subdomain_taken' => 'The subdomain is already in use', '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', 'client_login' => 'Client Login',
'converted_amount' => 'Converted Amount', 'converted_amount' => 'Converted Amount',
'default' => 'Default', 'default' => 'Default',

View File

@ -39,6 +39,9 @@ use App\Http\Controllers\CompanyController;
use App\Http\Controllers\ExpenseController; use App\Http\Controllers\ExpenseController;
use App\Http\Controllers\InvoiceController; use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\LicenseController; 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\PaymentController;
use App\Http\Controllers\PreviewController; use App\Http\Controllers\PreviewController;
use App\Http\Controllers\ProductController; use App\Http\Controllers\ProductController;
@ -50,7 +53,6 @@ use App\Http\Controllers\ActivityController;
use App\Http\Controllers\DocumentController; use App\Http\Controllers\DocumentController;
use App\Http\Controllers\PostMarkController; use App\Http\Controllers\PostMarkController;
use App\Http\Controllers\TemplateController; use App\Http\Controllers\TemplateController;
use App\Http\Controllers\MigrationController;
use App\Http\Controllers\SchedulerController; use App\Http\Controllers\SchedulerController;
use App\Http\Controllers\SubdomainController; use App\Http\Controllers\SubdomainController;
use App\Http\Controllers\SystemLogController; use App\Http\Controllers\SystemLogController;
@ -66,14 +68,12 @@ use App\Http\Controllers\PaymentTermController;
use App\PaymentDrivers\PayPalPPCPPaymentDriver; use App\PaymentDrivers\PayPalPPCPPaymentDriver;
use App\Http\Controllers\EmailHistoryController; use App\Http\Controllers\EmailHistoryController;
use App\Http\Controllers\GroupSettingController; use App\Http\Controllers\GroupSettingController;
use App\Http\Controllers\OneTimeTokenController;
use App\Http\Controllers\SubscriptionController; use App\Http\Controllers\SubscriptionController;
use App\Http\Controllers\Bank\NordigenController; use App\Http\Controllers\Bank\NordigenController;
use App\Http\Controllers\CompanyLedgerController; use App\Http\Controllers\CompanyLedgerController;
use App\Http\Controllers\PurchaseOrderController; use App\Http\Controllers\PurchaseOrderController;
use App\Http\Controllers\TaskSchedulerController; use App\Http\Controllers\TaskSchedulerController;
use App\Http\Controllers\CompanyGatewayController; use App\Http\Controllers\CompanyGatewayController;
use App\Http\Controllers\MailgunWebhookController;
use App\Http\Controllers\PaymentWebhookController; use App\Http\Controllers\PaymentWebhookController;
use App\Http\Controllers\RecurringQuoteController; use App\Http\Controllers\RecurringQuoteController;
use App\Http\Controllers\BankIntegrationController; use App\Http\Controllers\BankIntegrationController;
@ -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_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/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('token_hash_router', [OneTimeTokenController::class, 'router'])->middleware('throttle:500,1');
Route::get('webcron', [WebCronController::class, 'index'])->middleware('throttle:100,1'); Route::get('webcron', [WebCronController::class, 'index'])->middleware('throttle:100,1');
Route::post('api/v1/get_migration_account', [HostedMigrationController::class, 'getAccount'])->middleware('guest')->middleware('throttle:100,1'); Route::post('api/v1/get_migration_account', [HostedMigrationController::class, 'getAccount'])->middleware('guest')->middleware('throttle:100,1');

View File

@ -50,6 +50,49 @@ class CompanyTest extends TestCase
$this->makeTestData(); $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() public function testEnsureStrReplace()
{ {
$x = '**********'; $x = '**********';
@ -216,4 +259,6 @@ class CompanyTest extends TestCase
])->delete('/api/v1/companies/'.$this->encodePrimaryKey($company->id)) ])->delete('/api/v1/companies/'.$this->encodePrimaryKey($company->id))
->assertStatus(200); ->assertStatus(200);
} }
} }