File: /home/xedaptot/hi.naniguide.com/app/Services/UniboxService.php
<?php
namespace App\Services;
use App\Models\Setting;
use App\Models\UniboxAccount;
use App\Models\UniboxContact;
use App\Models\UniboxConversation;
use App\Models\UniboxMessage;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class UniboxService
{
protected ?UniboxAccount $account;
public function __construct(?UniboxAccount $account = null)
{
$this->account = $account;
}
public function syncAccount(UniboxAccount $account): array
{
if ($account->type === 'pop3') {
return ['synced' => 0, 'errors' => ['POP3 accounts are not supported for UniBox sync.']];
}
if ($account->oauth_refresh_token) {
return $this->syncGmailApi($account);
}
$synced = 0;
$errors = [];
try {
if (!function_exists('imap_open')) {
throw new \RuntimeException('PHP IMAP extension is not installed.');
}
$encMap = ['ssl' => '/ssl', 'tls' => '/tls', 'none' => '/notls'];
$enc = $encMap[$account->imap_encryption] ?? '/ssl';
$mailbox = "{{$account->imap_host}:{$account->imap_port}/imap{$enc}}INBOX";
$password = decrypt($account->imap_password);
$conn = @imap_open($mailbox, $account->imap_username, $password, 0, 1);
if (!$conn) {
throw new \RuntimeException('IMAP connection failed: ' . imap_last_error());
}
$since = $account->last_synced_at
? $account->last_synced_at->format('d-M-Y')
: now()->subDays(30)->format('d-M-Y');
$uids = imap_search($conn, "SINCE \"{$since}\"", SE_UID);
if ($uids) {
foreach (array_reverse($uids) as $uid) {
try {
$this->processMessage($conn, $uid, $account);
$synced++;
} catch (\Throwable $e) {
$errors[] = "UID {$uid}: " . $e->getMessage();
Log::warning("UniBox sync error for account #{$account->id}", ['uid' => $uid, 'error' => $e->getMessage()]);
}
}
}
imap_close($conn);
$account->update([
'last_synced_at' => now(),
'last_error' => null,
'status' => 'active',
]);
} catch (\Throwable $e) {
$account->update(['status' => 'error', 'last_error' => $e->getMessage()]);
$errors[] = $e->getMessage();
Log::error("UniBox account sync failed #{$account->id}", ['error' => $e->getMessage()]);
}
return ['synced' => $synced, 'errors' => $errors];
}
protected function processMessage($conn, int $uid, UniboxAccount $account): void
{
$header = imap_rfc822_parse_headers(imap_fetchheader($conn, $uid, FT_UID));
$messageId = trim($header->message_id ?? '');
$inReplyTo = trim($header->in_reply_to ?? '');
if ($messageId && UniboxMessage::where('message_id', $messageId)->exists()) {
return;
}
$fromAddr = $header->from[0] ?? null;
$fromEmail = $fromAddr ? strtolower($fromAddr->mailbox . '@' . $fromAddr->host) : '';
$fromName = isset($fromAddr->personal) ? imap_utf8($fromAddr->personal) : null;
$subject = imap_utf8($header->subject ?? '(no subject)');
$body = $this->fetchBody($conn, $uid);
// Find or create contact
$contact = UniboxContact::firstOrCreate(
['customer_id' => $account->customer_id, 'email' => $fromEmail],
['name' => $fromName, 'first_seen_at' => now()]
);
$contact->increment('conversation_count', 0);
$contact->update(['last_reply_at' => now()]);
// Find or create conversation
$threadId = $this->resolveThreadId($messageId, $inReplyTo, $account->customer_id);
$conversation = UniboxConversation::where('customer_id', $account->customer_id)
->where('thread_id', $threadId)
->first();
if (!$conversation) {
$conversation = UniboxConversation::create([
'customer_id' => $account->customer_id,
'account_id' => $account->id,
'contact_id' => $contact->id,
'subject' => $subject,
'thread_id' => $threadId,
'folder' => 'inbox',
'status' => 'open',
'is_read' => false,
'last_message_at' => now(),
'sla_deadline' => now()->addHours(2),
]);
$contact->increment('conversation_count');
} else {
$conversation->update(['last_message_at' => now(), 'is_read' => false]);
}
$to = $this->parseAddresses($header->to ?? []);
$cc = $this->parseAddresses($header->cc ?? []);
UniboxMessage::create([
'conversation_id' => $conversation->id,
'customer_id' => $account->customer_id,
'message_id' => $messageId,
'from_email' => $fromEmail,
'from_name' => $fromName,
'to' => $to,
'cc' => $cc,
'subject' => $subject,
'body_html' => $body['html'],
'body_text' => $body['text'],
'type' => 'inbound',
'status' => 'received',
'in_reply_to' => $inReplyTo,
'received_at' => now(),
]);
$conversation->increment('message_count');
}
protected function resolveThreadId(string $messageId, string $inReplyTo, int $customerId): string
{
if ($inReplyTo) {
$parent = UniboxMessage::where('message_id', $inReplyTo)->first();
if ($parent && $parent->conversation) {
return $parent->conversation->thread_id;
}
}
return $messageId ?: Str::uuid()->toString();
}
protected function fetchBody($conn, int $uid): array
{
$html = '';
$text = '';
$structure = imap_fetchstructure($conn, $uid, FT_UID);
if (!isset($structure->parts)) {
$body = imap_fetchbody($conn, $uid, '1', FT_UID);
$encoding = $structure->encoding ?? 0;
$decoded = $this->decodeBody($body, $encoding);
if ($structure->subtype === 'HTML') {
$html = $decoded;
} else {
$text = $decoded;
}
} else {
foreach ($structure->parts as $idx => $part) {
$partNum = $idx + 1;
$body = imap_fetchbody($conn, $uid, (string) $partNum, FT_UID);
$decoded = $this->decodeBody($body, $part->encoding ?? 0);
if (strtoupper($part->subtype ?? '') === 'HTML') {
$html = $decoded;
} elseif (strtoupper($part->subtype ?? '') === 'PLAIN') {
$text = $decoded;
}
}
}
return ['html' => $html, 'text' => $text];
}
protected function decodeBody(string $body, int $encoding): string
{
return match ($encoding) {
3 => base64_decode($body),
4 => quoted_printable_decode($body),
default => $body,
};
}
protected function parseAddresses(array $addresses): array
{
return array_map(fn($a) => [
'email' => strtolower(($a->mailbox ?? '') . '@' . ($a->host ?? '')),
'name' => isset($a->personal) ? imap_utf8($a->personal) : null,
], $addresses);
}
public function sendReply(UniboxConversation $conversation, array $data): UniboxMessage
{
$account = $conversation->account;
// Use the latest message in the thread (regardless of direction) so multi-turn
// replies stay threaded — not only the latest inbound one.
$lastMessage = $conversation->messages()->whereNotNull('message_id')->latest()->first();
$toEmail = $data['to'] ?? ($conversation->contact->email ?? '');
$subject = 'Re: ' . preg_replace('/^(re:\s*)+/i', '', (string) $conversation->subject);
$html = $data['body'] ?? '';
$text = strip_tags($html);
$inReplyTo = $lastMessage?->message_id ?: null;
// Build References chain from prior In-Reply-To plus this parent.
$referencesParts = [];
if ($lastMessage && $lastMessage->headers && isset($lastMessage->headers['references'])) {
$referencesParts[] = trim((string) $lastMessage->headers['references']);
}
if ($inReplyTo) {
$referencesParts[] = $inReplyTo;
}
$references = trim(implode(' ', array_filter($referencesParts)));
// Generate our own RFC Message-ID so we can persist it and match incoming replies later.
$host = parse_url(config('app.url') ?: '', PHP_URL_HOST) ?: 'mailpurse.local';
$localMessageId = '<' . bin2hex(random_bytes(16)) . '@' . $host . '>';
$email = (new \Symfony\Component\Mime\Email())
->from(new \Symfony\Component\Mime\Address($account->email, $account->name ?? ''))
->to($toEmail)
->subject($subject)
->html($html)
->text($text);
$attachments = $data['attachments'] ?? [];
foreach ($attachments as $att) {
$filePath = \Illuminate\Support\Facades\Storage::disk('local')->path($att['path']);
if (file_exists($filePath)) {
$email->attachFromPath($filePath, $att['name'], $att['mime'] ?? null);
}
}
// Set Message-ID explicitly so it survives the transport.
$email->getHeaders()->remove('Message-ID');
$email->getHeaders()->addIdHeader('Message-ID', trim($localMessageId, '<>'));
if ($inReplyTo) {
$email->getHeaders()->addTextHeader('In-Reply-To', $inReplyTo);
$email->getHeaders()->addTextHeader('References', $references ?: $inReplyTo);
}
// Gmail OAuth account: send via the Gmail API (no SMTP password available).
if ($account->oauth_refresh_token) {
// Only pass a Gmail threadId (hex string). RFC Message-IDs (contain < > or @)
// from legacy conversations aren't valid Gmail thread IDs.
$threadId = $conversation->thread_id;
$isGmailThreadId = $threadId && !preg_match('/[<>@]/', $threadId);
$this->sendViaGmailApi($account, $email, $isGmailThreadId ? $threadId : null);
} else {
$password = decrypt($account->smtp_password);
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$account->smtp_host,
$account->smtp_port,
$account->smtp_encryption !== 'none'
);
$transport->setUsername($account->smtp_username);
$transport->setPassword($password);
(new \Symfony\Component\Mailer\Mailer($transport))->send($email);
}
$message = UniboxMessage::create([
'conversation_id' => $conversation->id,
'customer_id' => $conversation->customer_id,
'message_id' => $localMessageId,
'from_email' => $account->email,
'from_name' => $account->name,
'to' => [['email' => $toEmail, 'name' => null]],
'subject' => $subject,
'body_html' => $html,
'body_text' => $text,
'type' => 'outbound',
'status' => 'sent',
'in_reply_to' => $inReplyTo,
'headers' => $references ? ['references' => $references] : null,
'attachments' => !empty($attachments) ? $attachments : null,
'sent_at' => now(),
]);
$conversation->update(['last_message_at' => now()]);
$conversation->increment('message_count');
return $message;
}
protected function sendViaGmailApi(UniboxAccount $account, \Symfony\Component\Mime\Email $email, ?string $threadId = null): void
{
$accessToken = $this->getGmailAccessToken($account);
$rawMime = $email->toString();
$encoded = rtrim(strtr(base64_encode($rawMime), '+/', '-_'), '=');
$payload = ['raw' => $encoded];
if ($threadId) {
$payload['threadId'] = $threadId;
}
$response = Http::withToken($accessToken)
->post('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', $payload);
if (!$response->successful()) {
throw new \RuntimeException('Gmail send failed: ' . $response->body());
}
}
public function sendEmail(array $data): void
{
$account = $this->account ?? null;
if (! $account) {
throw new \RuntimeException('No account set on UniboxService.');
}
$email = (new \Symfony\Component\Mime\Email())
->from(new \Symfony\Component\Mime\Address($account->email, $account->name ?? ''))
->subject((string) ($data['subject'] ?? ''))
->html((string) ($data['body'] ?? ''))
->text(strip_tags((string) ($data['body'] ?? '')));
foreach ((array) ($data['to'] ?? []) as $to) {
$email->addTo($to);
}
foreach ((array) ($data['cc'] ?? []) as $cc) {
$email->addCc($cc);
}
foreach ((array) ($data['bcc'] ?? []) as $bcc) {
$email->addBcc($bcc);
}
foreach ((array) ($data['attachments'] ?? []) as $attachment) {
if ($attachment instanceof UploadedFile) {
$realPath = $attachment->getRealPath();
if ($realPath && file_exists($realPath)) {
$email->attachFromPath($realPath, $attachment->getClientOriginalName(), $attachment->getMimeType());
}
continue;
}
if (is_array($attachment) && isset($attachment['path'])) {
$filePath = Storage::disk('local')->path($attachment['path']);
if (file_exists($filePath)) {
$email->attachFromPath($filePath, $attachment['name'] ?? basename($filePath), $attachment['mime'] ?? null);
}
}
}
if ($account->oauth_refresh_token) {
$this->sendViaGmailApi($account, $email);
return;
}
$password = decrypt($account->smtp_password);
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$account->smtp_host,
$account->smtp_port,
$account->smtp_encryption !== 'none'
);
$transport->setUsername($account->smtp_username);
$transport->setPassword($password);
(new \Symfony\Component\Mailer\Mailer($transport))->send($email);
}
protected function syncGmailApi(UniboxAccount $account): array
{
$synced = 0;
$errors = [];
try {
$accessToken = $this->getGmailAccessToken($account);
// Gmail's `after:` operator is reliable with Unix timestamps, but we step back
// a couple of minutes to catch in-flight messages from the previous sync.
$sinceTs = $account->last_synced_at
? $account->last_synced_at->subMinutes(5)->timestamp
: now()->subDays(30)->timestamp;
// Include inbox + sent + any other labeled items (excluding chats/spam/trash).
$query = 'after:' . $sinceTs . ' -in:chats -in:spam -in:trash';
$pageToken = null;
$messageIds = [];
$pages = 0;
do {
$params = ['q' => $query, 'maxResults' => 100];
if ($pageToken) {
$params['pageToken'] = $pageToken;
}
$listResponse = Http::withToken($accessToken)
->get('https://gmail.googleapis.com/gmail/v1/users/me/messages', $params);
if (!$listResponse->successful()) {
throw new \RuntimeException('Gmail list failed: ' . $listResponse->body());
}
foreach ($listResponse->json('messages') ?? [] as $m) {
$messageIds[] = $m['id'];
}
$pageToken = $listResponse->json('nextPageToken');
$pages++;
} while ($pageToken && $pages < 10); // hard cap at ~1000 messages per sync
foreach ($messageIds as $msgId) {
try {
$this->processGmailMessage($accessToken, $msgId, $account);
$synced++;
} catch (\Throwable $e) {
$errors[] = "Gmail msg {$msgId}: " . $e->getMessage();
Log::warning("UniBox Gmail sync error #{$account->id}", ['id' => $msgId, 'error' => $e->getMessage()]);
}
}
$account->update([
'last_synced_at' => now(),
'last_error' => null,
'status' => 'active',
]);
} catch (\Throwable $e) {
$account->update(['status' => 'error', 'last_error' => $e->getMessage()]);
$errors[] = $e->getMessage();
Log::error("UniBox Gmail API sync failed #{$account->id}", ['error' => $e->getMessage()]);
}
return ['synced' => $synced, 'errors' => $errors];
}
protected function getGmailAccessToken(UniboxAccount $account): string
{
if ($account->oauth_token && $account->oauth_expires_at && $account->oauth_expires_at->isFuture()) {
try {
return decrypt($account->oauth_token);
} catch (\Throwable $e) {
// fall through to refresh
}
}
$refreshToken = decrypt($account->oauth_refresh_token);
$clientId = Setting::get('google_client_id');
$clientSecret = Setting::get('google_client_secret');
if (!$clientId || !$clientSecret) {
throw new \RuntimeException('Gmail OAuth is not configured (missing client credentials).');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'refresh_token' => $refreshToken,
'grant_type' => 'refresh_token',
]);
if (!$response->successful()) {
throw new \RuntimeException('Gmail token refresh failed: ' . $response->body());
}
$data = $response->json();
$accessToken = $data['access_token'] ?? null;
$expiresIn = (int) ($data['expires_in'] ?? 3600);
if (!$accessToken) {
throw new \RuntimeException('Gmail token refresh returned no access_token.');
}
$account->update([
'oauth_token' => encrypt($accessToken),
'oauth_expires_at' => now()->addSeconds(max(60, $expiresIn - 60)),
]);
return $accessToken;
}
protected function processGmailMessage(string $accessToken, string $id, UniboxAccount $account): void
{
$response = Http::withToken($accessToken)
->get("https://gmail.googleapis.com/gmail/v1/users/me/messages/{$id}", [
'format' => 'full',
]);
if (!$response->successful()) {
throw new \RuntimeException('Gmail fetch failed: ' . $response->body());
}
$data = $response->json();
$payload = $data['payload'] ?? [];
$labelIds = $data['labelIds'] ?? [];
$category = $this->mapGmailCategory($labelIds);
$isSent = in_array('SENT', $labelIds, true);
$isInbox = in_array('INBOX', $labelIds, true);
$isUnread = in_array('UNREAD', $labelIds, true);
$headers = collect($payload['headers'] ?? [])->keyBy(fn($h) => strtolower($h['name']));
$messageId = trim($headers->get('message-id')['value'] ?? '');
$inReplyTo = trim($headers->get('in-reply-to')['value'] ?? '');
$gmailMessageId = $data['id'] ?? null;
$gmailThreadId = $data['threadId'] ?? null;
// Dedup against either the Gmail id or the RFC Message-ID — when we send a reply we
// store our locally-generated RFC Message-ID, and Gmail later returns the same message
// with that same RFC Message-ID, so we must check both forms.
$existing = UniboxMessage::query()
->where(function ($q) use ($gmailMessageId, $messageId) {
if ($gmailMessageId) {
$q->orWhere('message_id', $gmailMessageId);
}
if ($messageId) {
$q->orWhere('message_id', $messageId);
}
})
->exists();
if ($existing) {
return;
}
// Prefer the RFC Message-ID for storage so reply-resolution by In-Reply-To works.
$dedupId = $messageId ?: $gmailMessageId;
$fromHeader = $headers->get('from')['value'] ?? '';
$toHeaderRaw = $headers->get('to')['value'] ?? '';
[$fromEmail, $fromName] = $this->parseAddressHeader($fromHeader);
$subject = $headers->get('subject')['value'] ?? '(no subject)';
$body = $this->extractGmailBody($payload);
// Received timestamp from Gmail internalDate (epoch ms), otherwise now.
$receivedAt = isset($data['internalDate'])
? \Carbon\Carbon::createFromTimestampMs((int) $data['internalDate'])
: now();
// For the conversation's contact we use the "other side" of the conversation.
// For inbound messages that's the sender; for sent messages it's the first recipient.
if ($isSent) {
[$contactEmail, $contactName] = $this->parseAddressHeader(explode(',', $toHeaderRaw)[0] ?? '');
} else {
$contactEmail = $fromEmail;
$contactName = $fromName;
}
if (!$contactEmail) {
$contactEmail = $fromEmail ?: 'unknown@unknown';
}
$contact = UniboxContact::firstOrCreate(
['customer_id' => $account->customer_id, 'email' => $contactEmail],
['name' => $contactName, 'first_seen_at' => now()]
);
$contact->update(['last_reply_at' => $receivedAt]);
// Prefer Gmail's threadId for robust grouping.
$threadId = $gmailThreadId ?: $this->resolveThreadId($messageId, $inReplyTo, $account->customer_id);
$conversation = UniboxConversation::where('customer_id', $account->customer_id)
->where('thread_id', $threadId)
->first();
$folder = $isSent && !$isInbox ? 'sent' : 'inbox';
if (!$conversation) {
$conversation = UniboxConversation::create([
'customer_id' => $account->customer_id,
'account_id' => $account->id,
'contact_id' => $contact->id,
'subject' => $subject,
'thread_id' => $threadId,
'folder' => $folder,
'category' => $category,
'status' => 'open',
'is_read' => $isSent ? true : !$isUnread,
'last_message_at' => $receivedAt,
'sla_deadline' => now()->addHours(2),
]);
$contact->increment('conversation_count');
} else {
$update = [
'last_message_at' => $receivedAt,
'category' => $category ?: $conversation->category,
'account_id' => $account->id,
];
if (!$isSent) {
$update['is_read'] = !$isUnread;
// Only move back to inbox if the message is still in inbox.
if ($isInbox && $conversation->folder === 'sent') {
$update['folder'] = 'inbox';
}
}
$conversation->update($update);
}
$to = $this->parseAddressListHeader($toHeaderRaw);
$cc = $this->parseAddressListHeader($headers->get('cc')['value'] ?? '');
UniboxMessage::create([
'conversation_id' => $conversation->id,
'customer_id' => $account->customer_id,
'message_id' => $dedupId,
'from_email' => $fromEmail,
'from_name' => $fromName,
'to' => $to,
'cc' => $cc,
'subject' => $subject,
'body_html' => $body['html'],
'body_text' => $body['text'],
'type' => $isSent ? 'outbound' : 'inbound',
'status' => $isSent ? 'sent' : 'received',
'in_reply_to' => $inReplyTo,
'received_at' => $isSent ? null : $receivedAt,
'sent_at' => $isSent ? $receivedAt : null,
]);
$conversation->increment('message_count');
}
protected function mapGmailCategory(array $labelIds): ?string
{
$map = [
'CATEGORY_PERSONAL' => 'primary',
'CATEGORY_PROMOTIONS' => 'promotions',
'CATEGORY_UPDATES' => 'updates',
'CATEGORY_SOCIAL' => 'social',
'CATEGORY_FORUMS' => 'social',
];
foreach ($labelIds as $label) {
if (isset($map[$label])) {
return $map[$label];
}
}
return null;
}
protected function extractGmailBody(array $payload): array
{
$html = '';
$text = '';
$walk = function ($part) use (&$walk, &$html, &$text) {
$mime = $part['mimeType'] ?? '';
$data = $part['body']['data'] ?? null;
if ($data) {
$decoded = base64_decode(strtr($data, '-_', '+/'));
if ($mime === 'text/html' && $html === '') {
$html = $decoded;
} elseif ($mime === 'text/plain' && $text === '') {
$text = $decoded;
}
}
foreach ($part['parts'] ?? [] as $child) {
$walk($child);
}
};
$walk($payload);
return ['html' => $html, 'text' => $text];
}
protected function parseAddressHeader(string $header): array
{
$header = trim($header);
if ($header === '') {
return ['', null];
}
if (preg_match('/^(.*)<([^>]+)>$/', $header, $m)) {
return [strtolower(trim($m[2])), trim($m[1], " \t\"")];
}
return [strtolower($header), null];
}
protected function parseAddressListHeader(string $header): array
{
if (trim($header) === '') {
return [];
}
return array_map(function ($part) {
[$email, $name] = $this->parseAddressHeader($part);
return ['email' => $email, 'name' => $name];
}, array_filter(array_map('trim', explode(',', $header))));
}
public function testImapConnection(array $config): bool
{
if (!function_exists('imap_open')) {
throw new \RuntimeException('PHP IMAP extension is not installed.');
}
$encMap = ['ssl' => '/ssl', 'tls' => '/tls', 'none' => '/notls'];
$enc = $encMap[$config['imap_encryption']] ?? '/ssl';
$mailbox = "{{$config['imap_host']}:{$config['imap_port']}/imap{$enc}}INBOX";
$conn = @imap_open($mailbox, $config['imap_username'], $config['imap_password'], 0, 1);
if (!$conn) {
throw new \RuntimeException('Connection failed: ' . imap_last_error());
}
imap_close($conn);
return true;
}
}