1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-15 15:42:51 +01:00
invoiceninja/app/Services/InboundMail/InboundMailEngine.php

291 lines
11 KiB
PHP
Raw Normal View History

<?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\Events\Expense\ExpenseWasCreated;
use App\Factory\ExpenseFactory;
use App\Jobs\Util\SystemLogger;
use App\Libraries\MultiDB;
use App\Models\Client;
use App\Models\ClientContact;
use App\Models\Company;
use App\Models\SystemLog;
use App\Models\Vendor;
use App\Models\VendorContact;
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;
2023-12-18 17:21:15 +01:00
use Cache;
use Illuminate\Queue\SerializesModels;
2024-03-19 07:39:35 +01:00
use Log;
class InboundMailEngine
{
2024-03-18 08:04:54 +01:00
use SerializesModels, MakesHash;
use GeneratesCounter, SavesDocuments;
private ?Company $company;
2023-12-18 17:21:15 +01:00
private ?bool $isUnknownRecipent = null;
private array $globalBlacklistDomains = [];
private array $globalBlacklistSenders = [];
public function __construct(private InboundMail $email)
{
}
/**
* if there is not a company with an matching mailbox, we only do monitoring
2023-12-18 17:21:15 +01:00
* reuse this method to add more mail-parsing behaviors
*/
public function handle()
{
2023-12-18 17:21:15 +01:00
if ($this->isInvalidOrBlocked())
return;
2024-03-18 08:04:54 +01:00
2023-12-18 17:21:15 +01:00
$this->isUnknownRecipent = true;
// Expense Mailbox => will create an expense
$this->company = MultiDB::findAndSetDbByInboundMailbox($this->email->to);
2024-03-18 08:04:54 +01:00
if ($this->company) {
2023-12-18 17:21:15 +01:00
$this->isUnknownRecipent = false;
$this->createExpense();
}
2023-12-18 17:21:15 +01:00
$this->saveMeta();
}
2023-12-18 17:21:15 +01:00
// SPAM Protection
private function isInvalidOrBlocked()
{
// invalid email
if (!filter_var($this->email->from, FILTER_VALIDATE_EMAIL)) {
$this->logBlocked('E-Mail blocked, because from e-mail has the wrong format: ' . $this->email->from);
2023-12-18 17:21:15 +01:00
return true;
}
$parts = explode('@', $this->email->from);
$domain = array_pop($parts);
// global blacklist
if (in_array($domain, $this->globalBlacklistDomains)) {
$this->logBlocked('E-Mail blocked, because the domain was found on globalBlocklistDomains: ' . $this->email->from);
return true;
}
if (in_array($this->email->from, $this->globalBlacklistSenders)) {
$this->logBlocked('E-Mail blocked, because the email was found on globalBlocklistEmails: ' . $this->email->from);
2023-12-18 17:21:15 +01:00
return true;
}
if (Cache::has('inboundMailBlockedSender:' . $this->email->from)) { // was marked as blocked before, so we block without any console output
2023-12-18 17:21:15 +01:00
return true;
}
// sender occured in more than 500 emails in the last 12 hours
$senderMailCountTotal = Cache::get('inboundMailSender:' . $this->email->from, 0);
2023-12-18 17:21:15 +01:00
if ($senderMailCountTotal >= 5000) {
$this->logBlocked('E-Mail blocked permanent, because the sender sended more than ' . $senderMailCountTotal . ' emails in the last 12 hours: ' . $this->email->from);
2023-12-18 17:21:15 +01:00
$this->blockSender();
return true;
}
if ($senderMailCountTotal >= 1000) {
$this->logBlocked('E-Mail blocked, because the sender sended more than ' . $senderMailCountTotal . ' emails in the last 12 hours: ' . $this->email->from);
2023-12-18 17:21:15 +01:00
$this->saveMeta();
return true;
}
// sender sended more than 50 emails to the wrong mailbox in the last 6 hours
$senderMailCountUnknownRecipent = Cache::get('inboundMailSenderUnknownRecipent:' . $this->email->from, 0);
2023-12-18 17:21:15 +01:00
if ($senderMailCountUnknownRecipent >= 50) {
$this->logBlocked('E-Mail blocked, because the sender sended more than ' . $senderMailCountUnknownRecipent . ' emails to the wrong mailbox in the last 6 hours: ' . $this->email->from);
2023-12-18 17:21:15 +01:00
$this->saveMeta();
return true;
}
// wrong recipent occurs in more than 100 emails in the last 12 hours, so the processing is blocked
$mailCountUnknownRecipent = Cache::get('inboundMailUnknownRecipent:' . $this->email->to, 0); // @turbo124 maybe use many to save resources in case of spam with multiple to addresses each time
2024-03-18 08:04:54 +01:00
if ($mailCountUnknownRecipent >= 100) {
$this->logBlocked('E-Mail blocked, because anyone sended more than ' . $mailCountUnknownRecipent . ' emails to the wrong mailbox in the last 12 hours. Current sender was blocked as well: ' . $this->email->from);
2024-03-18 08:04:54 +01:00
$this->blockSender();
return true;
2023-12-18 17:21:15 +01:00
}
return false;
}
private function blockSender()
{
Cache::add('inboundMailBlockedSender:' . $this->email->from, true, now()->addHours(12));
2023-12-18 17:21:15 +01:00
$this->saveMeta();
2023-12-18 17:24:59 +01:00
// TODO: ignore, when known sender (for heavy email-usage mostly on isHosted())
2023-12-18 17:21:15 +01:00
// TODO: handle external blocking
}
private function saveMeta()
{
// save cache
Cache::add('inboundMailSender:' . $this->email->from, 0, now()->addHours(12));
Cache::increment('inboundMailSender:' . $this->email->from);
2023-12-18 17:21:15 +01:00
if ($this->isUnknownRecipent) {
Cache::add('inboundMailSenderUnknownRecipent:' . $this->email->from, 0, now()->addHours(6));
Cache::increment('inboundMailSenderUnknownRecipent:' . $this->email->from); // we save the sender, to may block him
2023-12-18 17:21:15 +01:00
Cache::add('inboundMailUnknownRecipent:' . $this->email->to, 0, now()->addHours(12));
Cache::increment('inboundMailUnknownRecipent:' . $this->email->to); // we save the sender, to may block him
2023-12-18 17:21:15 +01:00
}
}
2023-12-18 17:24:59 +01:00
// MAIL-PARSING
private function processHtmlBodyToDocument()
{
2024-03-19 07:39:35 +01:00
if ($this->email->body !== null)
$this->email->body_document = TempFile::UploadedFileFromRaw($this->email->body, "E-Mail.html", "text/html");
2024-03-19 07:39:35 +01:00
}
2023-12-18 17:24:59 +01:00
// MAIN-PROCESSORS
protected function createExpense()
{
// Skipping executions: will not result in not saving Metadata to prevent usage of these conditions, to spam
if (!$this->validateExpenseShouldProcess()) {
$this->logBlocked('mailbox not active for this company. from: ' . $this->email->from);
return;
}
2023-12-18 17:24:59 +01:00
if (!$this->validateExpenseSender()) {
$this->logBlocked('invalid sender of an ingest email for this company. from: ' . $this->email->from);
2023-12-18 17:24:59 +01:00
return;
}
if (sizeOf($this->email->documents) == 0) {
$this->logBlocked('email does not contain any attachments and is likly not an expense. from: ' . $this->email->from);
return;
}
2023-12-18 17:24:59 +01:00
// create expense
2023-12-18 17:24:59 +01:00
$expense = ExpenseFactory::create($this->company->id, $this->company->owner()->id);
$expense->public_notes = $this->email->subject;
$expense->private_notes = $this->email->text_body;
$expense->date = $this->email->date;
// handle vendor assignment
$expense_vendor = $this->getVendor();
2023-12-18 17:24:59 +01:00
if ($expense_vendor)
$expense->vendor_id = $expense_vendor->id;
// handle documents
$this->processHtmlBodyToDocument();
$documents = [];
array_push($documents, ...$this->email->documents);
2024-03-19 07:39:35 +01:00
if ($this->email->body_document !== null)
array_push($documents, $this->email->body_document);
2023-12-18 17:24:59 +01:00
$expense->saveQuietly();
2024-03-19 07:39:35 +01:00
$this->saveDocuments($documents, $expense);
event(new ExpenseWasCreated($expense, $expense->company, Ninja::eventVars(null))); // @turbo124 please check, I copied from API-Controller
event('eloquent.created: App\Models\Expense', $expense); // @turbo124 please check, I copied from API-Controller
2023-12-18 17:24:59 +01:00
}
2023-12-18 17:21:15 +01:00
// HELPERS
2023-12-18 17:24:59 +01:00
private function validateExpenseShouldProcess()
{
return $this->company?->inbound_mailbox_active ?: false;
}
private function validateExpenseSender()
{
$parts = explode('@', $this->email->from);
$domain = array_pop($parts);
// whitelists
$email_whitelist = explode(",", $this->company->inbound_mailbox_whitelist_senders);
if (in_array($this->email->from, $email_whitelist))
return true;
$domain_whitelist = explode(",", $this->company->inbound_mailbox_whitelist_domains);
if (in_array($domain, $domain_whitelist))
return true;
$email_blacklist = explode(",", $this->company->inbound_mailbox_blacklist_senders);
2024-03-19 07:55:55 +01:00
if (in_array($this->email->from, $email_blacklist))
return false;
$domain_blacklist = explode(",", $this->company->inbound_mailbox_blacklist_domains);
2024-03-19 07:55:55 +01:00
if (in_array($domain, $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", $this->email->from)->exists())
return true;
// from vendors (if active)
if ($this->company->inbound_mailbox_allow_vendors && $this->company->vendors()->where("invoicing_email", $this->email->from)->orWhere("invoicing_domain", $domain)->exists())
return true;
if ($this->company->inbound_mailbox_allow_vendors && $this->company->vendors()->contacts()->where("email", $this->email->from)->exists())
return true;
// from clients (if active)
if ($this->company->inbound_mailbox_allow_clients && $this->company->clients()->where("invoicing_email", $this->email->from)->orWhere("invoicing_domain", $domain)->exists())
return true;
if ($this->company->inbound_mailbox_allow_clients && $this->company->clients()->contacts()->where("email", $this->email->from)->exists())
return true;
// denie
return false;
}
private function getClient()
{
$parts = explode('@', $this->email->from);
$domain = array_pop($parts);
$client = Client::where("company_id", $this->company->id)->where("email", $domain)->first();
if ($client == null) {
$clientContact = ClientContact::where("company_id", $this->company->id)->where("email", $this->email->from)->first();
$client = $clientContact->client();
}
return $client;
}
private function getVendor()
{
2024-03-19 07:39:35 +01:00
$parts = explode('@', $this->email->from);
$domain = array_pop($parts);
$vendor = Vendor::where("company_id", $this->company->id)->where('invoicing_email', $this->email->from)->first();
if ($vendor == null)
2024-03-19 07:39:35 +01:00
$vendor = Vendor::where("company_id", $this->company->id)->where("invoicing_domain", $domain)->first();
if ($vendor == null) {
$vendorContact = VendorContact::where("company_id", $this->company->id)->where("email", $this->email->from)->first();
$vendor = $vendorContact->vendor();
}
return $vendor;
}
private function logBlocked(string $data)
{
Log::info("[InboundMailEngine][company:" . $this->company->id . "] " . $data);
(
new SystemLogger(
$data,
SystemLog::CATEGORY_MAIL,
SystemLog::EVENT_INBOUND_MAIL_BLOCKED,
SystemLog::TYPE_CUSTOM,
null,
$this->company
)
)->handle();
}
}