2026-05-04 16:46:14 +03:00

524 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Mail\WebmailMessageMail;
use App\Models\User;
use App\Models\UserMailboxSetting;
use App\Models\WebmailMessage;
use App\Repositories\WebmailMessageRepositoryInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Throwable;
use Webklex\PHPIMAP\Attribute;
use Webklex\PHPIMAP\ClientManager;
use Webklex\PHPIMAP\Message as ImapMessage;
class WebmailService
{
public function __construct(
private readonly WebmailMessageRepositoryInterface $webmailRepository
) {
}
/**
* @param array<string, mixed> $payload
*/
public function send(array $payload, User $user): WebmailMessage
{
$fromEmail = $user->email ?: config('mail.from.address');
$fromName = $user->name ?: config('mail.from.name');
$mailboxSetting = $user->mailboxSetting;
$message = $this->webmailRepository->create([
'user_id' => $user->id,
'message_uid' => $payload['message_uid'] ?? (string) Str::uuid(),
'direction' => 'outgoing',
'folder' => $payload['folder'] ?? 'sent',
'status' => 'pending',
'from_email' => $fromEmail,
'from_name' => $fromName,
'to_recipients' => $payload['to'],
'cc_recipients' => $payload['cc'] ?? [],
'bcc_recipients' => $payload['bcc'] ?? [],
'subject' => $payload['subject'] ?? null,
'body' => $payload['body'] ?? '',
'snippet' => $this->makeSnippet($payload['body'] ?? ''),
'attachments' => $payload['attachments'] ?? [],
'metadata' => $payload['metadata'] ?? [],
]);
try {
if ($mailboxSetting instanceof UserMailboxSetting && $mailboxSetting->hasSmtpConfiguration()) {
$this->sendUsingUserSmtp($payload, $mailboxSetting, $fromEmail, $fromName);
} else {
$mailable = new WebmailMessageMail([
'subject' => $payload['subject'] ?? '',
'body' => $payload['body'] ?? '',
]);
if (! empty($fromEmail)) {
$mailable->from($fromEmail, $fromName);
$mailable->replyTo($fromEmail, $fromName);
}
$mailer = Mail::to($payload['to']);
if (! empty($payload['cc'])) {
$mailer->cc($payload['cc']);
}
if (! empty($payload['bcc'])) {
$mailer->bcc($payload['bcc']);
}
$mailer->send($mailable);
}
$this->webmailRepository->update($message->id, [
'status' => 'sent',
'sent_at' => now(),
'metadata' => array_merge(Arr::wrap($message->metadata), [
'transport' => $mailboxSetting instanceof UserMailboxSetting && $mailboxSetting->hasSmtpConfiguration()
? 'user_smtp'
: 'app_mailer',
]),
]);
} catch (Throwable $throwable) {
$metadata = Arr::wrap($message->metadata);
$metadata['send_error'] = $throwable->getMessage();
$this->webmailRepository->update($message->id, [
'status' => 'failed',
'metadata' => $metadata,
]);
throw $throwable;
}
/** @var WebmailMessage $freshMessage */
$freshMessage = $this->webmailRepository->find((string) $message->id);
return $freshMessage;
}
/**
* @return array{imported:int, skipped:int, source:string}
*/
public function syncMailbox(User $user, int $limit = 30): array
{
$mailboxSetting = $user->mailboxSetting;
if ($mailboxSetting instanceof UserMailboxSetting && $mailboxSetting->hasImapConfiguration()) {
return $this->syncImapInbox($user, $mailboxSetting, $limit);
}
$result = $this->syncMailtrapInbox($user, $limit);
$result['source'] = 'mailtrap';
return $result;
}
/**
* @return array{imported:int, skipped:int}
*/
public function syncMailtrapInbox(User $user, int $limit = 30): array
{
$accountId = (string) config('services.mailtrap.account_id');
$inboxId = (string) config('services.mailtrap.inbox_id');
$token = (string) config('services.mailtrap.token');
$baseUrl = rtrim((string) config('services.mailtrap.base_url', 'https://mailtrap.io'), '/');
if ($accountId === '' || $inboxId === '' || $token === '') {
throw new \RuntimeException('La configuration Mailtrap est incomplete. Renseignez MAILTRAP_ACCOUNT_ID, MAILTRAP_INBOX_ID et MAILTRAP_API_TOKEN.');
}
if (empty($user->email)) {
throw new \RuntimeException('L\'utilisateur connecte ne possede pas d\'adresse email.');
}
$response = Http::baseUrl($baseUrl)
->acceptJson()
->withToken($token)
->get("/api/accounts/{$accountId}/inboxes/{$inboxId}/messages");
$response->throw();
/** @var array<int, array<string, mixed>> $messages */
$messages = $response->json();
$imported = 0;
$skipped = 0;
foreach (array_slice($messages, 0, max(1, $limit)) as $sandboxMessage) {
if (! $this->messageTargetsUser($sandboxMessage, (string) $user->email)) {
$skipped++;
continue;
}
$messageUid = 'mailtrap-' . (string) ($sandboxMessage['id'] ?? Str::uuid());
$alreadyExists = WebmailMessage::query()
->where('user_id', $user->id)
->where('message_uid', $messageUid)
->exists();
if ($alreadyExists) {
$skipped++;
continue;
}
$messageId = (string) ($sandboxMessage['id'] ?? '');
$body = $messageId !== ''
? $this->fetchMailtrapBody($baseUrl, $token, $accountId, $inboxId, $messageId)
: '';
$this->receive([
'message_uid' => $messageUid,
'from_email' => $sandboxMessage['from_email'] ?? 'unknown@mailtrap.local',
'from_name' => $sandboxMessage['from_name'] ?? null,
'to' => $this->extractRecipients($sandboxMessage),
'subject' => $sandboxMessage['subject'] ?? null,
'body' => $body,
'status' => 'received',
'folder' => 'inbox',
'received_at' => $sandboxMessage['sent_at'] ?? now()->toDateTimeString(),
'metadata' => [
'provider' => 'mailtrap',
'mailtrap_inbox_id' => $inboxId,
'mailtrap_message_id' => $sandboxMessage['id'] ?? null,
'mailtrap_raw' => $sandboxMessage,
],
], $user);
$imported++;
}
return [
'imported' => $imported,
'skipped' => $skipped,
];
}
/**
* @param array<string, mixed> $payload
*/
public function receive(array $payload, User $user): WebmailMessage
{
$receivedAt = !empty($payload['received_at'])
? Carbon::parse((string) $payload['received_at'])
: now();
/** @var WebmailMessage $message */
$message = $this->webmailRepository->create([
'user_id' => $user->id,
'message_uid' => $payload['message_uid'] ?? (string) Str::uuid(),
'direction' => 'incoming',
'folder' => $payload['folder'] ?? 'inbox',
'status' => $payload['status'] ?? 'received',
'from_email' => $payload['from_email'],
'from_name' => $payload['from_name'] ?? null,
'to_recipients' => $payload['to'],
'cc_recipients' => $payload['cc'] ?? [],
'bcc_recipients' => $payload['bcc'] ?? [],
'subject' => $payload['subject'] ?? null,
'body' => $payload['body'] ?? '',
'snippet' => $this->makeSnippet($payload['body'] ?? ''),
'attachments' => $payload['attachments'] ?? [],
'metadata' => $payload['metadata'] ?? [],
'received_at' => $receivedAt,
]);
return $message;
}
private function makeSnippet(string $body): string
{
return Str::limit(trim(strip_tags($body)), 160, '...');
}
private function sendUsingUserSmtp(
array $payload,
UserMailboxSetting $mailboxSetting,
string $fallbackFromEmail,
?string $fallbackFromName,
): void {
$transport = Transport::fromDsn($this->buildSmtpDsn($mailboxSetting));
$mailer = new Mailer($transport);
$fromEmail = $mailboxSetting->smtp_from_address ?: $fallbackFromEmail;
$fromName = $mailboxSetting->smtp_from_name ?: $fallbackFromName;
$htmlBody = view('emails.webmail_message', [
'body' => $payload['body'] ?? '',
])->render();
$email = (new Email())
->from(new Address($fromEmail, $fromName ?? ''))
->replyTo(new Address($fromEmail, $fromName ?? ''))
->subject((string) ($payload['subject'] ?? ''))
->html($htmlBody)
->text(trim(strip_tags((string) ($payload['body'] ?? ''))));
foreach ($payload['to'] as $recipient) {
$email->addTo(new Address((string) $recipient));
}
foreach ($payload['cc'] ?? [] as $recipient) {
$email->addCc(new Address((string) $recipient));
}
foreach ($payload['bcc'] ?? [] as $recipient) {
$email->addBcc(new Address((string) $recipient));
}
$mailer->send($email);
}
/**
* @return array{imported:int, skipped:int, source:string}
*/
private function syncImapInbox(User $user, UserMailboxSetting $mailboxSetting, int $limit): array
{
$imported = 0;
$skipped = 0;
try {
$clientManager = new ClientManager([
'options' => [
'fetch' => null,
],
'accounts' => [
'default' => [
'host' => $mailboxSetting->imap_host,
'port' => $mailboxSetting->imap_port,
'encryption' => $this->normalizeImapEncryption($mailboxSetting->imap_encryption),
'validate_cert' => $mailboxSetting->imap_validate_cert,
'username' => $mailboxSetting->imap_username,
'password' => $mailboxSetting->imap_password,
'protocol' => 'imap',
],
],
]);
$client = $clientManager->account('default');
$client->connect();
$folder = $client->getFolder($mailboxSetting->imap_folder ?: 'INBOX');
$query = $folder->query()
->leaveUnread()
->setFetchOrderDesc()
->limit(max(1, $limit));
if ($mailboxSetting->last_synced_at) {
$query->whereSince($mailboxSetting->last_synced_at->copy()->subDay());
}
/** @var iterable<int, ImapMessage> $messages */
$messages = $query->get();
foreach ($messages as $imapMessage) {
$messageUid = 'imap-' . (string) $imapMessage->getUid();
$alreadyExists = WebmailMessage::query()
->where('user_id', $user->id)
->where('message_uid', $messageUid)
->exists();
if ($alreadyExists) {
$skipped++;
continue;
}
$body = trim($imapMessage->getTextBody());
if ($body === '') {
$body = trim(strip_tags($imapMessage->getHTMLBody()));
}
$from = $this->extractAddressList($imapMessage->getFrom());
$to = $this->extractAddressList($imapMessage->getTo());
$cc = $this->extractAddressList($imapMessage->getCc());
$bcc = $this->extractAddressList($imapMessage->getBcc());
$this->receive([
'message_uid' => $messageUid,
'from_email' => $from[0]['email'] ?? 'unknown@imap.local',
'from_name' => $from[0]['name'] ?? null,
'to' => array_values(array_filter(array_map(fn (array $item): ?string => $item['email'] ?? null, $to))),
'cc' => array_values(array_filter(array_map(fn (array $item): ?string => $item['email'] ?? null, $cc))),
'bcc' => array_values(array_filter(array_map(fn (array $item): ?string => $item['email'] ?? null, $bcc))),
'subject' => (string) $imapMessage->getSubject(),
'body' => $body,
'status' => 'received',
'folder' => 'inbox',
'received_at' => $imapMessage->getDate()->toDate()->toDateTimeString(),
'attachments' => [],
'metadata' => [
'provider' => 'imap',
'imap_uid' => (string) $imapMessage->getUid(),
],
], $user);
$imported++;
}
$mailboxSetting->forceFill([
'last_synced_at' => now(),
'last_sync_error' => null,
])->save();
$client->disconnect();
} catch (Throwable $throwable) {
$mailboxSetting->forceFill([
'last_sync_error' => $throwable->getMessage(),
])->save();
throw $throwable;
}
return [
'imported' => $imported,
'skipped' => $skipped,
'source' => 'imap',
];
}
private function buildSmtpDsn(UserMailboxSetting $mailboxSetting): string
{
$scheme = ($mailboxSetting->smtp_encryption ?? '') === 'ssl' ? 'smtps' : 'smtp';
$username = rawurlencode((string) $mailboxSetting->smtp_username);
$password = rawurlencode((string) $mailboxSetting->smtp_password);
$host = (string) $mailboxSetting->smtp_host;
$port = (int) $mailboxSetting->smtp_port;
$query = [];
if (($mailboxSetting->smtp_encryption ?? '') === 'tls') {
$query['encryption'] = 'tls';
}
if (! $mailboxSetting->smtp_validate_cert) {
$query['verify_peer'] = '0';
}
$suffix = $query === [] ? '' : '?' . http_build_query($query);
return sprintf('%s://%s:%s@%s:%d%s', $scheme, $username, $password, $host, $port, $suffix);
}
private function normalizeImapEncryption(?string $encryption): ?string
{
if ($encryption === null || $encryption === '' || $encryption === 'none') {
return null;
}
return $encryption;
}
/**
* @return array<int, array{name:?string,email:?string}>
*/
private function extractAddressList(mixed $attribute): array
{
if (! $attribute instanceof Attribute) {
return [];
}
return collect($attribute->toArray())
->map(function (mixed $item): array {
if (is_object($item)) {
return [
'name' => isset($item->personal) ? (string) $item->personal : null,
'email' => isset($item->mail) ? (string) $item->mail : null,
];
}
if (is_array($item)) {
return [
'name' => isset($item['personal']) ? (string) $item['personal'] : null,
'email' => isset($item['mail']) ? (string) $item['mail'] : null,
];
}
return [
'name' => null,
'email' => is_string($item) ? $item : null,
];
})
->filter(fn (array $item): bool => filled($item['email']))
->values()
->all();
}
/**
* @param array<string, mixed> $sandboxMessage
*/
private function messageTargetsUser(array $sandboxMessage, string $userEmail): bool
{
$userEmail = Str::lower(trim($userEmail));
return collect($this->extractRecipients($sandboxMessage))
->map(fn (mixed $email): string => Str::lower(trim((string) $email)))
->contains($userEmail);
}
/**
* @param array<string, mixed> $sandboxMessage
* @return array<int, string>
*/
private function extractRecipients(array $sandboxMessage): array
{
$candidates = [
$sandboxMessage['to'] ?? null,
$sandboxMessage['to_email'] ?? null,
$sandboxMessage['to_emails'] ?? null,
];
return collect($candidates)
->flatten(1)
->map(function (mixed $recipient): ?string {
if (is_array($recipient)) {
$email = $recipient['email'] ?? $recipient['address'] ?? $recipient['to_email'] ?? null;
return is_string($email) ? trim($email) : null;
}
return is_string($recipient) ? trim($recipient) : null;
})
->filter(fn (?string $email): bool => ! empty($email))
->unique()
->values()
->all();
}
private function fetchMailtrapBody(string $baseUrl, string $token, string $accountId, string $inboxId, string $messageId): string
{
$textResponse = Http::baseUrl($baseUrl)
->withToken($token)
->accept('text/plain')
->get("/api/accounts/{$accountId}/inboxes/{$inboxId}/messages/{$messageId}/body.txt");
if ($textResponse->successful()) {
$body = trim($textResponse->body());
if ($body !== '') {
return $body;
}
}
$htmlResponse = Http::baseUrl($baseUrl)
->withToken($token)
->accept('text/html')
->get("/api/accounts/{$accountId}/inboxes/{$inboxId}/messages/{$messageId}/body.html");
if (! $htmlResponse->successful()) {
return '';
}
return trim(strip_tags($htmlResponse->body()));
}
}