CluPilotCloud/app/Services/Mail/IngestInboundMail.php

104 lines
3.5 KiB
PHP

<?php
namespace App\Services\Mail;
use App\Models\Customer;
use App\Models\InboundMail;
use App\Models\SupportRequest;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Log;
/**
* Turning fetched messages into rows an operator can work through.
*
* Three decisions, and only three: is this one already here, whose is it, and
* does it belong to a question already open.
*
* Whose it is, is decided by the sender's ADDRESS and nothing else. Not by a
* name in the subject line and not by anything in the body: a mail is easy to
* write and this decision attaches a stranger's words to a real customer's
* file. An address nobody recognises stays unassigned, which is a state to work
* in and not an error — a new enquiry starts exactly that way.
*/
class IngestInboundMail
{
public function __construct(private readonly InboundMailbox $mailbox) {}
/**
* Fetch, store, and flag what was stored.
*
* @return int how many new messages were taken in
*/
public function __invoke(int $limit = 50): int
{
if (! $this->mailbox->isConfigured()) {
return 0;
}
$taken = 0;
foreach ($this->mailbox->fetch($limit) as $mail) {
$stored = $this->store($mail);
if ($stored) {
$taken++;
}
// Flagged only once the row is safely written — including for a
// duplicate, which IS safely written, just earlier. Flagging first
// and failing after would lose the message with nothing to show
// for it, and there is no second copy anywhere.
$this->mailbox->markSeen($mail->uid);
}
return $taken;
}
private function store(FetchedMail $mail): bool
{
$customer = Customer::query()->where('email', $mail->fromEmail)->first();
try {
InboundMail::create([
'customer_id' => $customer?->id,
'support_request_id' => $customer === null ? null : $this->openRequestFor($customer)?->id,
'message_id' => $mail->messageId,
'from_email' => $mail->fromEmail,
'from_name' => $mail->fromName,
'subject' => $mail->subject,
'body' => $mail->body,
'attachments' => $mail->attachments === [] ? null : $mail->attachments,
'received_at' => $mail->receivedAt,
]);
} catch (UniqueConstraintViolationException) {
// Already here. The fetch runs every few minutes and a flag that
// did not stick on the server costs one re-read — which is exactly
// what this catch turns into nothing.
return false;
} catch (\Throwable $e) {
Log::error('Could not store an inbound mail', ['exception' => $e]);
return false;
}
return true;
}
/**
* The question this is probably an answer to.
*
* The customer's most recent request that is still open. A guess, and a
* shallow one on purpose — it only pre-fills a link an operator can undo,
* and threading by References/In-Reply-To would need the whole outgoing
* side to carry stable ids first.
*/
private function openRequestFor(Customer $customer): ?SupportRequest
{
return SupportRequest::query()
->where('customer_id', $customer->id)
->whereNotIn('status', SupportRequest::CLOSED)
->latest('id')
->first();
}
}