IMAP webmailing

This commit is contained in:
kevin 2026-05-04 16:46:14 +03:00
parent c4977bad15
commit 9440098815
26 changed files with 2852 additions and 190 deletions

View File

@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\ReceiveWebmailMessageRequest;
use App\Http\Requests\SendWebmailMessageRequest;
use App\Http\Requests\UpsertUserMailboxSettingRequest;
use App\Http\Requests\UpdateWebmailMessageRequest;
use App\Http\Resources\Webmail\UserMailboxSettingResource;
use App\Http\Resources\Webmail\WebmailMessageResource;
use App\Models\UserMailboxSetting;
use App\Models\WebmailMessage;
use App\Repositories\WebmailMessageRepositoryInterface;
use App\Services\WebmailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class WebmailController extends Controller
{
public function __construct(
private readonly WebmailMessageRepositoryInterface $webmailRepository,
private readonly WebmailService $webmailService,
) {
}
public function index(Request $request): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
$messages = $this->webmailRepository->paginateForUser(
(int) $user->id,
[
'folder' => $request->query('folder'),
'status' => $request->query('status'),
'search' => $request->query('search'),
'unread' => $request->has('unread') ? $request->boolean('unread') : null,
'starred' => $request->has('starred') ? $request->boolean('starred') : null,
],
max(1, (int) $request->integer('per_page', 15)),
);
return response()->json([
'data' => $messages->getCollection()
->map(fn (WebmailMessage $message): array => (new WebmailMessageResource($message))->resolve())
->values(),
'meta' => [
'current_page' => $messages->currentPage(),
'last_page' => $messages->lastPage(),
'per_page' => $messages->perPage(),
'total' => $messages->total(),
],
'message' => 'Messages recuperes avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error fetching webmail messages: ' . $e->getMessage(), [
'exception' => $e,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recuperation des messages.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function stats(): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
return response()->json([
'data' => $this->webmailRepository->statsForUser((int) $user->id),
'message' => 'Statistiques webmail recuperees avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error fetching webmail stats: ' . $e->getMessage(), [
'exception' => $e,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recuperation des statistiques webmail.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function show(string $id): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
$message = $this->webmailRepository->findForUser($id, (int) $user->id);
if (! $message) {
return response()->json([
'message' => 'Message non trouve.',
], 404);
}
return response()->json([
'data' => (new WebmailMessageResource($message))->resolve(),
'message' => 'Message recupere avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error fetching webmail message: ' . $e->getMessage(), [
'exception' => $e,
'message_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la recuperation du message.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function send(SendWebmailMessageRequest $request): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
$message = $this->webmailService->send($request->validated(), $user);
return response()->json([
'data' => (new WebmailMessageResource($message))->resolve(),
'message' => 'Email envoye avec succes.',
], 201);
} catch (\Exception $e) {
Log::error('Error sending webmail message: ' . $e->getMessage(), [
'exception' => $e,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de l\'envoi de l\'email.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function receive(ReceiveWebmailMessageRequest $request): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
$message = $this->webmailService->receive($request->validated(), $user);
return response()->json([
'data' => (new WebmailMessageResource($message))->resolve(),
'message' => 'Email recu et enregistre avec succes.',
], 201);
} catch (\Exception $e) {
Log::error('Error receiving webmail message: ' . $e->getMessage(), [
'exception' => $e,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de l\'enregistrement du message recu.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function syncMailtrap(Request $request): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
$result = $this->webmailService->syncMailtrapInbox(
$user,
max(1, min(50, (int) $request->integer('limit', 30)))
);
return response()->json([
'data' => $result,
'message' => 'Synchronisation Mailtrap terminee avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error syncing Mailtrap webmail messages: ' . $e->getMessage(), [
'exception' => $e,
'user_id' => Auth::id(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la synchronisation Mailtrap.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function sync(Request $request): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
$result = $this->webmailService->syncMailbox(
$user->loadMissing('mailboxSetting'),
max(1, min(50, (int) $request->integer('limit', 30)))
);
return response()->json([
'data' => $result,
'message' => 'Synchronisation de la boite mail terminee avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error syncing mailbox webmail messages: ' . $e->getMessage(), [
'exception' => $e,
'user_id' => Auth::id(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la synchronisation de la boite mail.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function mailboxSettings(): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
return response()->json([
'data' => $user->relationLoaded('mailboxSetting')
? ($user->mailboxSetting ? (new UserMailboxSettingResource($user->mailboxSetting))->resolve() : null)
: ($user->mailboxSetting ? (new UserMailboxSettingResource($user->mailboxSetting))->resolve() : null),
'message' => 'Configuration mailbox recuperee avec succes.',
]);
}
public function upsertMailboxSettings(UpsertUserMailboxSettingRequest $request): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
$validated = $request->validated();
$clearImapPassword = (bool) ($validated['clear_imap_password'] ?? false);
$clearSmtpPassword = (bool) ($validated['clear_smtp_password'] ?? false);
unset($validated['clear_imap_password'], $validated['clear_smtp_password']);
if ($clearImapPassword) {
$validated['imap_password'] = null;
} elseif (array_key_exists('imap_password', $validated) && $validated['imap_password'] === null) {
unset($validated['imap_password']);
}
if ($clearSmtpPassword) {
$validated['smtp_password'] = null;
} elseif (array_key_exists('smtp_password', $validated) && $validated['smtp_password'] === null) {
unset($validated['smtp_password']);
}
/** @var UserMailboxSetting $settings */
$settings = UserMailboxSetting::query()->updateOrCreate(
['user_id' => $user->id],
$validated,
);
return response()->json([
'data' => (new UserMailboxSettingResource($settings))->resolve(),
'message' => 'Configuration mailbox mise a jour avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error updating mailbox settings: ' . $e->getMessage(), [
'exception' => $e,
'user_id' => Auth::id(),
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise a jour de la configuration mailbox.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function update(UpdateWebmailMessageRequest $request, string $id): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
$message = $this->webmailRepository->findForUser($id, (int) $user->id);
if (! $message) {
return response()->json([
'message' => 'Message non trouve.',
], 404);
}
$validated = $request->validated();
if (array_key_exists('is_read', $validated)) {
$validated['read_at'] = $validated['is_read'] ? now() : null;
unset($validated['is_read']);
}
if (array_key_exists('is_starred', $validated)) {
$validated['starred_at'] = $validated['is_starred'] ? now() : null;
unset($validated['is_starred']);
}
$updated = $this->webmailRepository->update($id, $validated);
if (! $updated) {
return response()->json([
'message' => 'Echec de la mise a jour du message.',
], 422);
}
/** @var WebmailMessage $freshMessage */
$freshMessage = $this->webmailRepository->findForUser($id, (int) $user->id);
return response()->json([
'data' => (new WebmailMessageResource($freshMessage))->resolve(),
'message' => 'Message mis a jour avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error updating webmail message: ' . $e->getMessage(), [
'exception' => $e,
'message_id' => $id,
'data' => $request->validated(),
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la mise a jour du message.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
public function destroy(string $id): JsonResponse
{
try {
$user = Auth::user();
if (! $user) {
return response()->json([
'message' => 'Utilisateur non authentifie.',
], 401);
}
$message = $this->webmailRepository->findForUser($id, (int) $user->id);
if (! $message) {
return response()->json([
'message' => 'Message non trouve.',
], 404);
}
$deleted = $this->webmailRepository->delete($id);
if (! $deleted) {
return response()->json([
'message' => 'Echec de la suppression du message.',
], 422);
}
return response()->json([
'message' => 'Message supprime avec succes.',
]);
} catch (\Exception $e) {
Log::error('Error deleting webmail message: ' . $e->getMessage(), [
'exception' => $e,
'message_id' => $id,
]);
return response()->json([
'message' => 'Une erreur est survenue lors de la suppression du message.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ReceiveWebmailMessageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'from_email' => ['required', 'email:rfc,dns'],
'from_name' => ['nullable', 'string', 'max:255'],
'to' => ['required', 'array', 'min:1'],
'to.*' => ['required', 'email:rfc,dns'],
'cc' => ['nullable', 'array'],
'cc.*' => ['email:rfc,dns'],
'bcc' => ['nullable', 'array'],
'bcc.*' => ['email:rfc,dns'],
'subject' => ['nullable', 'string', 'max:255'],
'body' => ['nullable', 'string'],
'folder' => ['nullable', 'string', 'max:30'],
'status' => ['nullable', 'string', 'max:30'],
'received_at' => ['nullable', 'date'],
'attachments' => ['nullable', 'array'],
'metadata' => ['nullable', 'array'],
'message_uid' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SendWebmailMessageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'to' => ['required', 'array', 'min:1'],
'to.*' => ['required', 'email:rfc,dns'],
'cc' => ['nullable', 'array'],
'cc.*' => ['email:rfc,dns'],
'bcc' => ['nullable', 'array'],
'bcc.*' => ['email:rfc,dns'],
'subject' => ['nullable', 'string', 'max:255'],
'body' => ['required', 'string'],
'folder' => ['nullable', 'string', 'max:30'],
'attachments' => ['nullable', 'array'],
'metadata' => ['nullable', 'array'],
'message_uid' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateWebmailMessageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'folder' => ['nullable', 'string', 'max:30'],
'status' => ['nullable', 'string', 'max:30'],
'is_read' => ['nullable', 'boolean'],
'is_starred' => ['nullable', 'boolean'],
'subject' => ['nullable', 'string', 'max:255'],
'body' => ['nullable', 'string'],
'metadata' => ['nullable', 'array'],
];
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpsertUserMailboxSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'imap_host' => ['nullable', 'string', 'max:255'],
'imap_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'imap_encryption' => ['nullable', 'string', 'in:ssl,tls,starttls,none'],
'imap_validate_cert' => ['nullable', 'boolean'],
'imap_username' => ['nullable', 'string', 'max:255'],
'imap_password' => ['nullable', 'string', 'max:500'],
'imap_folder' => ['nullable', 'string', 'max:255'],
'smtp_host' => ['nullable', 'string', 'max:255'],
'smtp_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'smtp_encryption' => ['nullable', 'string', 'in:ssl,tls,none'],
'smtp_validate_cert' => ['nullable', 'boolean'],
'smtp_username' => ['nullable', 'string', 'max:255'],
'smtp_password' => ['nullable', 'string', 'max:500'],
'smtp_from_address' => ['nullable', 'email:rfc,dns'],
'smtp_from_name' => ['nullable', 'string', 'max:255'],
'clear_imap_password' => ['nullable', 'boolean'],
'clear_smtp_password' => ['nullable', 'boolean'],
];
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Webmail;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserMailboxSettingResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'user_id' => $this->user_id,
'imap_host' => $this->imap_host,
'imap_port' => $this->imap_port,
'imap_encryption' => $this->imap_encryption,
'imap_validate_cert' => (bool) $this->imap_validate_cert,
'imap_username' => $this->imap_username,
'imap_folder' => $this->imap_folder,
'imap_password_configured' => filled($this->imap_password),
'smtp_host' => $this->smtp_host,
'smtp_port' => $this->smtp_port,
'smtp_encryption' => $this->smtp_encryption,
'smtp_validate_cert' => (bool) $this->smtp_validate_cert,
'smtp_username' => $this->smtp_username,
'smtp_from_address' => $this->smtp_from_address,
'smtp_from_name' => $this->smtp_from_name,
'smtp_password_configured' => filled($this->smtp_password),
'has_imap_configuration' => $this->hasImapConfiguration(),
'has_smtp_configuration' => $this->hasSmtpConfiguration(),
'last_synced_at' => $this->last_synced_at,
'last_sync_error' => $this->last_sync_error,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Webmail;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WebmailMessageResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'user_id' => $this->user_id,
'message_uid' => $this->message_uid,
'direction' => $this->direction,
'folder' => $this->folder,
'status' => $this->status,
'from_email' => $this->from_email,
'from_name' => $this->from_name,
'to' => $this->to_recipients ?? [],
'cc' => $this->cc_recipients ?? [],
'bcc' => $this->bcc_recipients ?? [],
'subject' => $this->subject,
'body' => $this->body,
'snippet' => $this->snippet,
'attachments' => $this->attachments ?? [],
'metadata' => $this->metadata ?? [],
'is_read' => $this->read_at !== null,
'is_starred' => $this->starred_at !== null,
'read_at' => $this->read_at,
'starred_at' => $this->starred_at,
'sent_at' => $this->sent_at,
'received_at' => $this->received_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class WebmailMessageMail extends Mailable
{
use Queueable, SerializesModels;
/**
* @param array<string, string> $payload
*/
public function __construct(public array $payload)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->payload['subject'] ?? 'Nouveau message',
);
}
public function content(): Content
{
return new Content(
view: 'emails.webmail_message',
with: [
'body' => $this->payload['body'] ?? '',
],
);
}
}

View File

@ -60,4 +60,9 @@ class User extends Authenticatable
{
return $this->hasOne(Employee::class);
}
public function mailboxSetting(): HasOne
{
return $this->hasOne(UserMailboxSetting::class);
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserMailboxSetting extends Model
{
use HasFactory;
/**
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'imap_host',
'imap_port',
'imap_encryption',
'imap_validate_cert',
'imap_username',
'imap_password',
'imap_folder',
'smtp_host',
'smtp_port',
'smtp_encryption',
'smtp_validate_cert',
'smtp_username',
'smtp_password',
'smtp_from_address',
'smtp_from_name',
'last_synced_at',
'last_sync_error',
];
/**
* @var array<string, string>
*/
protected $casts = [
'imap_validate_cert' => 'boolean',
'smtp_validate_cert' => 'boolean',
'imap_password' => 'encrypted',
'smtp_password' => 'encrypted',
'last_synced_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function hasImapConfiguration(): bool
{
return filled($this->imap_host)
&& filled($this->imap_port)
&& filled($this->imap_username)
&& filled($this->imap_password);
}
public function hasSmtpConfiguration(): bool
{
return filled($this->smtp_host)
&& filled($this->smtp_port)
&& filled($this->smtp_username)
&& filled($this->smtp_password);
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebmailMessage extends Model
{
use HasFactory;
/**
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'message_uid',
'direction',
'folder',
'status',
'from_email',
'from_name',
'to_recipients',
'cc_recipients',
'bcc_recipients',
'subject',
'body',
'snippet',
'attachments',
'metadata',
'read_at',
'starred_at',
'sent_at',
'received_at',
];
/**
* @var array<string, string>
*/
protected $casts = [
'to_recipients' => 'array',
'cc_recipients' => 'array',
'bcc_recipients' => 'array',
'attachments' => 'array',
'metadata' => 'array',
'read_at' => 'datetime',
'starred_at' => 'datetime',
'sent_at' => 'datetime',
'received_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -12,6 +12,8 @@ use App\Repositories\InterventionRepositoryInterface;
use App\Repositories\InterventionRepository;
use App\Repositories\FileRepositoryInterface;
use App\Repositories\FileRepository;
use App\Repositories\WebmailMessageRepository;
use App\Repositories\WebmailMessageRepositoryInterface;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
@ -26,6 +28,7 @@ class RepositoryServiceProvider extends ServiceProvider
$this->app->bind(DeceasedDocumentRepositoryInterface::class, DeceasedDocumentRepository::class);
$this->app->bind(InterventionRepositoryInterface::class, InterventionRepository::class);
$this->app->bind(FileRepositoryInterface::class, FileRepository::class);
$this->app->bind(WebmailMessageRepositoryInterface::class, WebmailMessageRepository::class);
$this->app->bind(\App\Repositories\PurchaseOrderRepositoryInterface::class, \App\Repositories\PurchaseOrderRepository::class);
$this->app->bind(\App\Repositories\WarehouseRepositoryInterface::class, \App\Repositories\WarehouseRepository::class);
$this->app->bind(\App\Repositories\StockItemRepositoryInterface::class, \App\Repositories\StockItemRepository::class);

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\WebmailMessage;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class WebmailMessageRepository extends BaseRepository implements WebmailMessageRepositoryInterface
{
public function __construct(WebmailMessage $model)
{
parent::__construct($model);
}
public function paginateForUser(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = $this->model->newQuery()
->where('user_id', $userId)
->orderByRaw('COALESCE(received_at, sent_at, created_at) DESC');
if (!empty($filters['folder'])) {
$query->where('folder', $filters['folder']);
}
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (array_key_exists('unread', $filters) && $filters['unread'] !== null) {
$filters['unread']
? $query->whereNull('read_at')
: $query->whereNotNull('read_at');
}
if (array_key_exists('starred', $filters) && $filters['starred'] !== null) {
$filters['starred']
? $query->whereNotNull('starred_at')
: $query->whereNull('starred_at');
}
if (!empty($filters['search'])) {
$search = '%' . trim((string) $filters['search']) . '%';
$query->where(function ($builder) use ($search): void {
$builder
->where('subject', 'like', $search)
->orWhere('from_email', 'like', $search)
->orWhere('from_name', 'like', $search)
->orWhere('body', 'like', $search)
->orWhere('snippet', 'like', $search);
});
}
return $query->paginate($perPage);
}
public function findForUser(int|string $id, int $userId): ?WebmailMessage
{
$message = $this->model->newQuery()
->where('user_id', $userId)
->find($id);
return $message instanceof WebmailMessage ? $message : null;
}
public function statsForUser(int $userId): array
{
$baseQuery = $this->model->newQuery()->where('user_id', $userId);
return [
'total' => (clone $baseQuery)->count(),
'inbox' => (clone $baseQuery)->where('folder', 'inbox')->count(),
'sent' => (clone $baseQuery)->where('folder', 'sent')->count(),
'drafts' => (clone $baseQuery)->where('folder', 'drafts')->count(),
'trash' => (clone $baseQuery)->where('folder', 'trash')->count(),
'unread' => (clone $baseQuery)->whereNull('read_at')->count(),
'starred' => (clone $baseQuery)->whereNotNull('starred_at')->count(),
];
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\WebmailMessage;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface WebmailMessageRepositoryInterface extends BaseRepositoryInterface
{
/**
* @param array<string, mixed> $filters
*/
public function paginateForUser(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator;
public function findForUser(int|string $id, int $userId): ?WebmailMessage;
/**
* @return array<string, int>
*/
public function statsForUser(int $userId): array;
}

View File

@ -0,0 +1,524 @@
<?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()));
}
}

View File

@ -13,8 +13,9 @@
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.2",
"laravel/tinker": "^2.10.1",
"spatie/laravel-permission": "^6.18",
"laravel/tinker": "^2.10.1"
"webklex/php-imap": "^6.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",

View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "39694481426b03a733a81beaf6531e56",
"content-hash": "74fc1ffaa567d424ef63bdd4f9dea808",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@ -6699,6 +6699,87 @@
],
"time": "2024-11-21T01:49:47+00:00"
},
{
"name": "webklex/php-imap",
"version": "6.2.0",
"source": {
"type": "git",
"url": "https://github.com/Webklex/php-imap.git",
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"ext-iconv": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"ext-zip": "*",
"illuminate/pagination": ">=5.0.0",
"nesbot/carbon": "^2.62.1|^3.2.4",
"php": "^8.0.2",
"symfony/http-foundation": ">=2.8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5.10"
},
"suggest": {
"symfony/mime": "Recomended for better extension support",
"symfony/var-dumper": "Usefull tool for debugging"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "6.0-dev"
}
},
"autoload": {
"psr-4": {
"Webklex\\PHPIMAP\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Malte Goldenbaum",
"email": "github@webklex.com",
"role": "Developer"
}
],
"description": "PHP IMAP client",
"homepage": "https://github.com/webklex/php-imap",
"keywords": [
"imap",
"mail",
"php-imap",
"pop3",
"webklex"
],
"support": {
"issues": "https://github.com/Webklex/php-imap/issues",
"source": "https://github.com/Webklex/php-imap/tree/6.2.0"
},
"funding": [
{
"url": "https://www.buymeacoffee.com/webklex",
"type": "custom"
},
{
"url": "https://ko-fi.com/webklex",
"type": "ko_fi"
}
],
"time": "2025-04-25T06:02:37+00:00"
},
{
"name": "webmozart/assert",
"version": "1.11.0",

View File

@ -35,4 +35,11 @@ return [
],
],
'mailtrap' => [
'base_url' => env('MAILTRAP_BASE_URL', 'https://mailtrap.io'),
'account_id' => env('MAILTRAP_ACCOUNT_ID'),
'inbox_id' => env('MAILTRAP_INBOX_ID'),
'token' => env('MAILTRAP_API_TOKEN'),
],
];

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('webmail_messages', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('message_uid')->nullable()->index();
$table->string('direction', 20);
$table->string('folder', 30)->default('inbox')->index();
$table->string('status', 30)->default('received')->index();
$table->string('from_email')->nullable();
$table->string('from_name')->nullable();
$table->json('to_recipients');
$table->json('cc_recipients')->nullable();
$table->json('bcc_recipients')->nullable();
$table->string('subject')->nullable();
$table->longText('body')->nullable();
$table->text('snippet')->nullable();
$table->json('attachments')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('read_at')->nullable()->index();
$table->timestamp('starred_at')->nullable()->index();
$table->timestamp('sent_at')->nullable()->index();
$table->timestamp('received_at')->nullable()->index();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('webmail_messages');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_mailbox_settings', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->string('imap_host')->nullable();
$table->unsignedSmallInteger('imap_port')->nullable();
$table->string('imap_encryption', 10)->nullable();
$table->boolean('imap_validate_cert')->default(true);
$table->string('imap_username')->nullable();
$table->text('imap_password')->nullable();
$table->string('imap_folder')->default('INBOX');
$table->string('smtp_host')->nullable();
$table->unsignedSmallInteger('smtp_port')->nullable();
$table->string('smtp_encryption', 10)->nullable();
$table->boolean('smtp_validate_cert')->default(true);
$table->string('smtp_username')->nullable();
$table->text('smtp_password')->nullable();
$table->string('smtp_from_address')->nullable();
$table->string('smtp_from_name')->nullable();
$table->timestamp('last_synced_at')->nullable();
$table->text('last_sync_error')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('user_mailbox_settings');
}
};

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email</title>
</head>
<body style="margin:0;padding:24px;background:#f8f9fa;font-family:Arial,sans-serif;color:#344767;">
<div style="max-width:680px;margin:0 auto;background:#ffffff;border-radius:12px;padding:24px;border:1px solid #e9ecef;">
{!! $body !!}
</div>
</body>
</html>

View File

@ -28,6 +28,7 @@ use App\Http\Controllers\Api\GoodsReceiptController;
use App\Http\Controllers\Api\UserController;
use App\Http\Controllers\Api\VehicleController;
use App\Http\Controllers\Api\ConvoyController;
use App\Http\Controllers\Api\WebmailController;
/*
@ -66,6 +67,19 @@ Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('client-groups', ClientGroupController::class);
Route::apiResource('price-lists', PriceListController::class);
Route::apiResource('users', UserController::class);
Route::prefix('webmail')->group(function () {
Route::get('settings', [WebmailController::class, 'mailboxSettings']);
Route::put('settings', [WebmailController::class, 'upsertMailboxSettings']);
Route::get('messages/stats', [WebmailController::class, 'stats']);
Route::get('messages', [WebmailController::class, 'index']);
Route::post('messages/send', [WebmailController::class, 'send']);
Route::post('messages/receive', [WebmailController::class, 'receive']);
Route::post('messages/sync', [WebmailController::class, 'sync']);
Route::post('messages/sync-mailtrap', [WebmailController::class, 'syncMailtrap']);
Route::get('messages/{id}', [WebmailController::class, 'show']);
Route::patch('messages/{id}', [WebmailController::class, 'update']);
Route::delete('messages/{id}', [WebmailController::class, 'destroy']);
});
Route::middleware('permission:config.view_roles')->group(function () {
Route::get('access-control', [AccessControlController::class, 'index']);
Route::post('access-control/roles', [AccessControlController::class, 'storeRole']);

View File

@ -1,2 +1,3 @@
export * from "./http";
export { default as AuthService } from "./auth";
export { default as WebmailService } from "./webmail";

View File

@ -0,0 +1,254 @@
import { request } from "./http";
export interface WebmailMessage {
id: number;
user_id: number;
message_uid: string | null;
direction: "incoming" | "outgoing";
folder: string;
status: string;
from_email: string | null;
from_name: string | null;
to: string[];
cc: string[];
bcc: string[];
subject: string | null;
body: string | null;
snippet: string | null;
attachments: any[];
metadata: Record<string, any>;
is_read: boolean;
is_starred: boolean;
read_at: string | null;
starred_at: string | null;
sent_at: string | null;
received_at: string | null;
created_at: string;
updated_at: string;
}
export interface WebmailListMeta {
current_page: number;
last_page: number;
per_page: number;
total: number;
}
export interface WebmailListResponse {
data: WebmailMessage[];
meta: WebmailListMeta;
message: string;
}
export interface WebmailItemResponse {
data: WebmailMessage;
message: string;
}
export interface WebmailStats {
total: number;
inbox: number;
sent: number;
drafts: number;
trash: number;
unread: number;
starred: number;
}
export interface WebmailStatsResponse {
data: WebmailStats;
message: string;
}
export interface WebmailSyncResult {
imported: number;
skipped: number;
}
export interface WebmailSyncResponse {
data: WebmailSyncResult;
message: string;
}
export interface WebmailMailboxSettings {
id: number;
user_id: number;
imap_host: string | null;
imap_port: number | null;
imap_encryption: string | null;
imap_validate_cert: boolean;
imap_username: string | null;
imap_folder: string | null;
imap_password_configured: boolean;
smtp_host: string | null;
smtp_port: number | null;
smtp_encryption: string | null;
smtp_validate_cert: boolean;
smtp_username: string | null;
smtp_from_address: string | null;
smtp_from_name: string | null;
smtp_password_configured: boolean;
has_imap_configuration: boolean;
has_smtp_configuration: boolean;
last_synced_at: string | null;
last_sync_error: string | null;
}
export interface WebmailMailboxSettingsResponse {
data: WebmailMailboxSettings | null;
message: string;
}
export interface WebmailQueryParams {
folder?: string;
status?: string;
search?: string;
unread?: boolean;
starred?: boolean;
per_page?: number;
}
export interface SendWebmailPayload {
to: string[];
cc?: string[];
bcc?: string[];
subject?: string;
body: string;
folder?: string;
attachments?: any[];
metadata?: Record<string, any>;
message_uid?: string;
}
export interface UpdateWebmailPayload {
folder?: string;
status?: string;
is_read?: boolean;
is_starred?: boolean;
subject?: string;
body?: string;
metadata?: Record<string, any>;
}
export interface UpsertMailboxSettingsPayload {
imap_host?: string | null;
imap_port?: number | null;
imap_encryption?: string | null;
imap_validate_cert?: boolean;
imap_username?: string | null;
imap_password?: string | null;
imap_folder?: string | null;
smtp_host?: string | null;
smtp_port?: number | null;
smtp_encryption?: string | null;
smtp_validate_cert?: boolean;
smtp_username?: string | null;
smtp_password?: string | null;
smtp_from_address?: string | null;
smtp_from_name?: string | null;
clear_imap_password?: boolean;
clear_smtp_password?: boolean;
}
export const WebmailService = {
async getMessages(
params: WebmailQueryParams = {}
): Promise<WebmailListResponse> {
return request<WebmailListResponse>({
url: "/api/webmail/messages",
method: "get",
params,
});
},
async getMessage(id: number): Promise<WebmailMessage> {
const response = await request<WebmailItemResponse>({
url: `/api/webmail/messages/${id}`,
method: "get",
});
return response.data;
},
async getStats(): Promise<WebmailStats> {
const response = await request<WebmailStatsResponse>({
url: "/api/webmail/messages/stats",
method: "get",
});
return response.data;
},
async sendMessage(payload: SendWebmailPayload): Promise<WebmailMessage> {
const response = await request<WebmailItemResponse>({
url: "/api/webmail/messages/send",
method: "post",
data: payload,
});
return response.data;
},
async getMailboxSettings(): Promise<WebmailMailboxSettings | null> {
const response = await request<WebmailMailboxSettingsResponse>({
url: "/api/webmail/settings",
method: "get",
});
return response.data;
},
async updateMailboxSettings(
payload: UpsertMailboxSettingsPayload
): Promise<WebmailMailboxSettings | null> {
const response = await request<WebmailMailboxSettingsResponse>({
url: "/api/webmail/settings",
method: "put",
data: payload,
});
return response.data;
},
async syncMailbox(limit = 30): Promise<WebmailSyncResult> {
const response = await request<WebmailSyncResponse>({
url: "/api/webmail/messages/sync",
method: "post",
params: { limit },
});
return response.data;
},
async syncMailtrap(limit = 30): Promise<WebmailSyncResult> {
const response = await request<WebmailSyncResponse>({
url: "/api/webmail/messages/sync-mailtrap",
method: "post",
params: { limit },
});
return response.data;
},
async updateMessage(
id: number,
payload: UpdateWebmailPayload
): Promise<WebmailMessage> {
const response = await request<WebmailItemResponse>({
url: `/api/webmail/messages/${id}`,
method: "patch",
data: payload,
});
return response.data;
},
async deleteMessage(id: number): Promise<{ message: string }> {
return request<{ message: string }>({
url: `/api/webmail/messages/${id}`,
method: "delete",
});
},
};
export default WebmailService;

View File

@ -0,0 +1,279 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import WebmailService from "@/services/webmail";
import type {
WebmailMailboxSettings,
SendWebmailPayload,
UpdateWebmailPayload,
UpsertMailboxSettingsPayload,
WebmailMessage,
WebmailQueryParams,
WebmailSyncResult,
WebmailStats,
} from "@/services/webmail";
const emptyStats: WebmailStats = {
total: 0,
inbox: 0,
sent: 0,
drafts: 0,
trash: 0,
unread: 0,
starred: 0,
};
export const useWebmailStore = defineStore("webmail", () => {
const loading = ref(false);
const sending = ref(false);
const syncing = ref(false);
const mailboxSettingsLoading = ref(false);
const error = ref<string | null>(null);
const messages = ref<WebmailMessage[]>([]);
const currentMessage = ref<WebmailMessage | null>(null);
const mailboxSettings = ref<WebmailMailboxSettings | null>(null);
const stats = ref<WebmailStats>({ ...emptyStats });
const pagination = ref({
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
});
const allMessages = computed(() => messages.value);
const selectedMessage = computed(() => currentMessage.value);
const isLoading = computed(() => loading.value);
const isSending = computed(() => sending.value);
const isSyncing = computed(() => syncing.value);
const isMailboxSettingsLoading = computed(() => mailboxSettingsLoading.value);
const getStats = computed(() => stats.value);
const currentMailboxSettings = computed(() => mailboxSettings.value);
const fetchMessages = async (params: WebmailQueryParams = {}) => {
loading.value = true;
error.value = null;
try {
const response = await WebmailService.getMessages(params);
messages.value = response.data;
pagination.value = response.meta;
if (currentMessage.value) {
currentMessage.value =
response.data.find((item) => item.id === currentMessage.value?.id) ||
response.data[0] ||
null;
} else {
currentMessage.value = response.data[0] || null;
}
return response.data;
} catch (err: any) {
error.value =
err.response?.data?.message ||
err.message ||
"Failed to fetch messages";
throw err;
} finally {
loading.value = false;
}
};
const fetchMessage = async (id: number) => {
loading.value = true;
error.value = null;
try {
const message = await WebmailService.getMessage(id);
currentMessage.value = message;
messages.value = messages.value.map((item) =>
item.id === id ? message : item
);
return message;
} catch (err: any) {
error.value =
err.response?.data?.message || err.message || "Failed to fetch message";
throw err;
} finally {
loading.value = false;
}
};
const fetchStats = async () => {
try {
stats.value = await WebmailService.getStats();
return stats.value;
} catch (err: any) {
error.value =
err.response?.data?.message ||
err.message ||
"Failed to fetch webmail stats";
throw err;
}
};
const sendMessage = async (payload: SendWebmailPayload) => {
sending.value = true;
error.value = null;
try {
const message = await WebmailService.sendMessage(payload);
messages.value = [
message,
...messages.value.filter((item) => item.id !== message.id),
];
currentMessage.value = message;
await fetchStats();
return message;
} catch (err: any) {
error.value =
err.response?.data?.message || err.message || "Failed to send message";
throw err;
} finally {
sending.value = false;
}
};
const fetchMailboxSettings = async () => {
mailboxSettingsLoading.value = true;
error.value = null;
try {
mailboxSettings.value = await WebmailService.getMailboxSettings();
return mailboxSettings.value;
} catch (err: any) {
error.value =
err.response?.data?.message ||
err.message ||
"Failed to fetch mailbox settings";
throw err;
} finally {
mailboxSettingsLoading.value = false;
}
};
const updateMailboxSettings = async (
payload: UpsertMailboxSettingsPayload
) => {
mailboxSettingsLoading.value = true;
error.value = null;
try {
mailboxSettings.value = await WebmailService.updateMailboxSettings(
payload
);
return mailboxSettings.value;
} catch (err: any) {
error.value =
err.response?.data?.message ||
err.message ||
"Failed to update mailbox settings";
throw err;
} finally {
mailboxSettingsLoading.value = false;
}
};
const syncMailbox = async (
params: WebmailQueryParams = {},
limit = 30
): Promise<WebmailSyncResult> => {
syncing.value = true;
error.value = null;
try {
const result = await WebmailService.syncMailbox(limit);
await Promise.all([
fetchMessages(params),
fetchStats(),
fetchMailboxSettings(),
]);
return result;
} catch (err: any) {
error.value =
err.response?.data?.message || err.message || "Failed to sync mailbox";
throw err;
} finally {
syncing.value = false;
}
};
const updateMessage = async (id: number, payload: UpdateWebmailPayload) => {
loading.value = true;
error.value = null;
try {
const message = await WebmailService.updateMessage(id, payload);
messages.value = messages.value.map((item) =>
item.id === id ? message : item
);
if (currentMessage.value?.id === id) {
currentMessage.value = message;
}
await fetchStats();
return message;
} catch (err: any) {
error.value =
err.response?.data?.message ||
err.message ||
"Failed to update message";
throw err;
} finally {
loading.value = false;
}
};
const deleteMessage = async (id: number) => {
loading.value = true;
error.value = null;
try {
const response = await WebmailService.deleteMessage(id);
messages.value = messages.value.filter((item) => item.id !== id);
if (currentMessage.value?.id === id) {
currentMessage.value = messages.value[0] || null;
}
await fetchStats();
return response;
} catch (err: any) {
error.value =
err.response?.data?.message ||
err.message ||
"Failed to delete message";
throw err;
} finally {
loading.value = false;
}
};
return {
loading,
sending,
syncing,
mailboxSettingsLoading,
error,
messages,
currentMessage,
mailboxSettings,
stats,
pagination,
allMessages,
selectedMessage,
isLoading,
isSending,
isSyncing,
isMailboxSettingsLoading,
getStats,
currentMailboxSettings,
fetchMessages,
fetchMessage,
fetchStats,
fetchMailboxSettings,
sendMessage,
updateMailboxSettings,
syncMailbox,
updateMessage,
deleteMessage,
};
});
export default useWebmailStore;