524 lines
18 KiB
PHP
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()));
|
|
}
|
|
} |