104 lines
3.5 KiB
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();
|
|
}
|
|
}
|