From 94400988153459d589febc8fe47ad14c6826ae42 Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 4 May 2026 16:46:14 +0300 Subject: [PATCH] IMAP webmailing --- .../Controllers/Api/WebmailController.php | 440 ++++++++++ .../Requests/ReceiveWebmailMessageRequest.php | 40 + .../Requests/SendWebmailMessageRequest.php | 36 + .../Requests/UpdateWebmailMessageRequest.php | 31 + .../UpsertUserMailboxSettingRequest.php | 41 + .../Webmail/UserMailboxSettingResource.php | 43 + .../Webmail/WebmailMessageResource.php | 44 + .../app/Mail/WebmailMessageMail.php | 40 + thanasoft-back/app/Models/User.php | 5 + .../app/Models/UserMailboxSetting.php | 70 ++ thanasoft-back/app/Models/WebmailMessage.php | 59 ++ .../Providers/RepositoryServiceProvider.php | 3 + .../Repositories/WebmailMessageRepository.php | 82 ++ .../WebmailMessageRepositoryInterface.php | 23 + .../app/Services/WebmailService.php | 524 +++++++++++ thanasoft-back/composer.json | 3 +- thanasoft-back/composer.lock | 83 +- thanasoft-back/config/services.php | 7 + ...4_120000_create_webmail_messages_table.php | 40 + ...000_create_user_mailbox_settings_table.php | 39 + .../views/emails/webmail_message.blade.php | 13 + thanasoft-back/routes/api.php | 14 + .../Webmailing/WebmailingPresentation.vue | 828 ++++++++++++++---- thanasoft-front/src/services/index.ts | 1 + thanasoft-front/src/services/webmail.ts | 254 ++++++ thanasoft-front/src/stores/webmailStore.ts | 279 ++++++ 26 files changed, 2852 insertions(+), 190 deletions(-) create mode 100644 thanasoft-back/app/Http/Controllers/Api/WebmailController.php create mode 100644 thanasoft-back/app/Http/Requests/ReceiveWebmailMessageRequest.php create mode 100644 thanasoft-back/app/Http/Requests/SendWebmailMessageRequest.php create mode 100644 thanasoft-back/app/Http/Requests/UpdateWebmailMessageRequest.php create mode 100644 thanasoft-back/app/Http/Requests/UpsertUserMailboxSettingRequest.php create mode 100644 thanasoft-back/app/Http/Resources/Webmail/UserMailboxSettingResource.php create mode 100644 thanasoft-back/app/Http/Resources/Webmail/WebmailMessageResource.php create mode 100644 thanasoft-back/app/Mail/WebmailMessageMail.php create mode 100644 thanasoft-back/app/Models/UserMailboxSetting.php create mode 100644 thanasoft-back/app/Models/WebmailMessage.php create mode 100644 thanasoft-back/app/Repositories/WebmailMessageRepository.php create mode 100644 thanasoft-back/app/Repositories/WebmailMessageRepositoryInterface.php create mode 100644 thanasoft-back/app/Services/WebmailService.php create mode 100644 thanasoft-back/database/migrations/2026_05_04_120000_create_webmail_messages_table.php create mode 100644 thanasoft-back/database/migrations/2026_05_04_170000_create_user_mailbox_settings_table.php create mode 100644 thanasoft-back/resources/views/emails/webmail_message.blade.php create mode 100644 thanasoft-front/src/services/webmail.ts create mode 100644 thanasoft-front/src/stores/webmailStore.ts diff --git a/thanasoft-back/app/Http/Controllers/Api/WebmailController.php b/thanasoft-back/app/Http/Controllers/Api/WebmailController.php new file mode 100644 index 0000000..af48ab1 --- /dev/null +++ b/thanasoft-back/app/Http/Controllers/Api/WebmailController.php @@ -0,0 +1,440 @@ +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); + } + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Http/Requests/ReceiveWebmailMessageRequest.php b/thanasoft-back/app/Http/Requests/ReceiveWebmailMessageRequest.php new file mode 100644 index 0000000..8ffc9b9 --- /dev/null +++ b/thanasoft-back/app/Http/Requests/ReceiveWebmailMessageRequest.php @@ -0,0 +1,40 @@ + + */ + 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'], + ]; + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Http/Requests/SendWebmailMessageRequest.php b/thanasoft-back/app/Http/Requests/SendWebmailMessageRequest.php new file mode 100644 index 0000000..c5b56a2 --- /dev/null +++ b/thanasoft-back/app/Http/Requests/SendWebmailMessageRequest.php @@ -0,0 +1,36 @@ + + */ + 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'], + ]; + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Http/Requests/UpdateWebmailMessageRequest.php b/thanasoft-back/app/Http/Requests/UpdateWebmailMessageRequest.php new file mode 100644 index 0000000..a1321a4 --- /dev/null +++ b/thanasoft-back/app/Http/Requests/UpdateWebmailMessageRequest.php @@ -0,0 +1,31 @@ + + */ + 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'], + ]; + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Http/Requests/UpsertUserMailboxSettingRequest.php b/thanasoft-back/app/Http/Requests/UpsertUserMailboxSettingRequest.php new file mode 100644 index 0000000..2f39341 --- /dev/null +++ b/thanasoft-back/app/Http/Requests/UpsertUserMailboxSettingRequest.php @@ -0,0 +1,41 @@ + + */ + 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'], + ]; + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Http/Resources/Webmail/UserMailboxSettingResource.php b/thanasoft-back/app/Http/Resources/Webmail/UserMailboxSettingResource.php new file mode 100644 index 0000000..3909c80 --- /dev/null +++ b/thanasoft-back/app/Http/Resources/Webmail/UserMailboxSettingResource.php @@ -0,0 +1,43 @@ + + */ + 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, + ]; + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Http/Resources/Webmail/WebmailMessageResource.php b/thanasoft-back/app/Http/Resources/Webmail/WebmailMessageResource.php new file mode 100644 index 0000000..eee4c58 --- /dev/null +++ b/thanasoft-back/app/Http/Resources/Webmail/WebmailMessageResource.php @@ -0,0 +1,44 @@ + + */ + 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, + ]; + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Mail/WebmailMessageMail.php b/thanasoft-back/app/Mail/WebmailMessageMail.php new file mode 100644 index 0000000..55ca6e0 --- /dev/null +++ b/thanasoft-back/app/Mail/WebmailMessageMail.php @@ -0,0 +1,40 @@ + $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'] ?? '', + ], + ); + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Models/User.php b/thanasoft-back/app/Models/User.php index f8e6965..86f671a 100644 --- a/thanasoft-back/app/Models/User.php +++ b/thanasoft-back/app/Models/User.php @@ -60,4 +60,9 @@ class User extends Authenticatable { return $this->hasOne(Employee::class); } + + public function mailboxSetting(): HasOne + { + return $this->hasOne(UserMailboxSetting::class); + } } diff --git a/thanasoft-back/app/Models/UserMailboxSetting.php b/thanasoft-back/app/Models/UserMailboxSetting.php new file mode 100644 index 0000000..4159aad --- /dev/null +++ b/thanasoft-back/app/Models/UserMailboxSetting.php @@ -0,0 +1,70 @@ + + */ + 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 + */ + 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); + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Models/WebmailMessage.php b/thanasoft-back/app/Models/WebmailMessage.php new file mode 100644 index 0000000..f9b6712 --- /dev/null +++ b/thanasoft-back/app/Models/WebmailMessage.php @@ -0,0 +1,59 @@ + + */ + 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 + */ + 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); + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Providers/RepositoryServiceProvider.php b/thanasoft-back/app/Providers/RepositoryServiceProvider.php index db782e5..129bc49 100644 --- a/thanasoft-back/app/Providers/RepositoryServiceProvider.php +++ b/thanasoft-back/app/Providers/RepositoryServiceProvider.php @@ -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); diff --git a/thanasoft-back/app/Repositories/WebmailMessageRepository.php b/thanasoft-back/app/Repositories/WebmailMessageRepository.php new file mode 100644 index 0000000..b65a8a5 --- /dev/null +++ b/thanasoft-back/app/Repositories/WebmailMessageRepository.php @@ -0,0 +1,82 @@ +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(), + ]; + } +} \ No newline at end of file diff --git a/thanasoft-back/app/Repositories/WebmailMessageRepositoryInterface.php b/thanasoft-back/app/Repositories/WebmailMessageRepositoryInterface.php new file mode 100644 index 0000000..1d73f89 --- /dev/null +++ b/thanasoft-back/app/Repositories/WebmailMessageRepositoryInterface.php @@ -0,0 +1,23 @@ + $filters + */ + public function paginateForUser(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator; + + public function findForUser(int|string $id, int $userId): ?WebmailMessage; + + /** + * @return array + */ + public function statsForUser(int $userId): array; +} \ No newline at end of file diff --git a/thanasoft-back/app/Services/WebmailService.php b/thanasoft-back/app/Services/WebmailService.php new file mode 100644 index 0000000..57c5c83 --- /dev/null +++ b/thanasoft-back/app/Services/WebmailService.php @@ -0,0 +1,524 @@ + $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> $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 $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 $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 + */ + 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 $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 $sandboxMessage + * @return array + */ + 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())); + } +} \ No newline at end of file diff --git a/thanasoft-back/composer.json b/thanasoft-back/composer.json index 8427daf..2f0f5b7 100644 --- a/thanasoft-back/composer.json +++ b/thanasoft-back/composer.json @@ -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", diff --git a/thanasoft-back/composer.lock b/thanasoft-back/composer.lock index e67d992..9f8af1e 100644 --- a/thanasoft-back/composer.lock +++ b/thanasoft-back/composer.lock @@ -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", diff --git a/thanasoft-back/config/services.php b/thanasoft-back/config/services.php index 6182e4b..a2d46c1 100644 --- a/thanasoft-back/config/services.php +++ b/thanasoft-back/config/services.php @@ -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'), + ], + ]; diff --git a/thanasoft-back/database/migrations/2026_05_04_120000_create_webmail_messages_table.php b/thanasoft-back/database/migrations/2026_05_04_120000_create_webmail_messages_table.php new file mode 100644 index 0000000..0ee2bd0 --- /dev/null +++ b/thanasoft-back/database/migrations/2026_05_04_120000_create_webmail_messages_table.php @@ -0,0 +1,40 @@ +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'); + } +}; \ No newline at end of file diff --git a/thanasoft-back/database/migrations/2026_05_04_170000_create_user_mailbox_settings_table.php b/thanasoft-back/database/migrations/2026_05_04_170000_create_user_mailbox_settings_table.php new file mode 100644 index 0000000..892d547 --- /dev/null +++ b/thanasoft-back/database/migrations/2026_05_04_170000_create_user_mailbox_settings_table.php @@ -0,0 +1,39 @@ +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'); + } +}; \ No newline at end of file diff --git a/thanasoft-back/resources/views/emails/webmail_message.blade.php b/thanasoft-back/resources/views/emails/webmail_message.blade.php new file mode 100644 index 0000000..d1bc994 --- /dev/null +++ b/thanasoft-back/resources/views/emails/webmail_message.blade.php @@ -0,0 +1,13 @@ + + + + + + Email + + +
+ {!! $body !!} +
+ + \ No newline at end of file diff --git a/thanasoft-back/routes/api.php b/thanasoft-back/routes/api.php index 7763e49..f3bfcfd 100644 --- a/thanasoft-back/routes/api.php +++ b/thanasoft-back/routes/api.php @@ -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']); diff --git a/thanasoft-front/src/components/Organism/Webmailing/WebmailingPresentation.vue b/thanasoft-front/src/components/Organism/Webmailing/WebmailingPresentation.vue index dc7dccc..6237036 100644 --- a/thanasoft-front/src/components/Organism/Webmailing/WebmailingPresentation.vue +++ b/thanasoft-front/src/components/Organism/Webmailing/WebmailingPresentation.vue @@ -7,6 +7,7 @@ variant="gradient" size="sm" class="webmail-compose" + @click="openCompose" > Compose @@ -43,9 +44,6 @@
Labels -
@@ -72,18 +70,31 @@
-

Storage

-

255.13 MB / 500.00 MB

+

Mailbox

+

{{ stats.total }} messages synchronised

- 51% + {{ + webmailStore.isSyncing ? "sync..." : `${stats.unread} unread` + }}
- + -
@@ -108,33 +119,53 @@
-
+
+
+ Loading... +
+
+ +
+

{{ storeError }}

+ + Retry + +
+ +
+

No messages available in this folder.

+
+ +
@@ -150,50 +181,130 @@ color="light" size="sm" class="btn-icon-only webmail-icon-button" + @click="openCompose" > - +
-
+
+
+
+
+

Compose message

+

Send a message using the Laravel Webmail API.

+
+ + Cancel + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Discard + + + + {{ webmailStore.isSending ? "Sending..." : "Send" }} + +
+
+
+ +
-

{{ currentMessage.subject }}

+

{{ currentMessage.subject || "(No subject)" }}

- +
-

{{ currentMessage.email }}

+

{{ getPrimaryEmail(currentMessage) }}

@@ -204,227 +315,515 @@
-

{{ currentMessage.date }}

+

{{ getFullDate(currentMessage) }}

-

+

{{ paragraph }}

- + Reply - + Forward
+ +
+

Select a message or start composing.

+
@@ -527,12 +926,6 @@ const currentMessage = computed(() => { font-weight: 700; } -.webmail-sidebar-section__action { - border: 0; - background: transparent; - color: inherit; -} - .webmail-label-item { width: 100%; border: 0; @@ -674,6 +1067,23 @@ const currentMessage = computed(() => { min-height: 0; } +.webmail-empty-state { + min-height: 16rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + color: #67748e; + font-size: 0.82rem; + padding: 1rem; + text-align: center; +} + +.webmail-empty-state--error { + color: #f53939; +} + .webmail-message-item { width: 100%; border: 0; @@ -868,6 +1278,47 @@ const currentMessage = computed(() => { .webmail-message-body p { margin-bottom: 1rem; + white-space: pre-wrap; +} + +.webmail-compose-panel { + max-width: 48rem; +} + +.webmail-compose-panel__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.webmail-compose-panel__head h2 { + margin: 0 0 0.3rem; + color: #344767; + font-size: 1.05rem; + font-weight: 700; +} + +.webmail-compose-panel__head p { + margin: 0; + color: #8392ab; + font-size: 0.75rem; +} + +.webmail-field-label { + display: block; + margin-bottom: 0.35rem; + color: #344767; + font-size: 0.75rem; + font-weight: 600; +} + +.webmail-compose-textarea { + min-height: 15rem; + font-size: 0.85rem; + color: #344767; + border-color: #d2d6da; } .webmail-message-actions { @@ -893,7 +1344,8 @@ const currentMessage = computed(() => { } @media (max-width: 768px) { - .webmail-detail-head { + .webmail-detail-head, + .webmail-compose-panel__head { flex-direction: column; } diff --git a/thanasoft-front/src/services/index.ts b/thanasoft-front/src/services/index.ts index a8c0d32..c90087e 100644 --- a/thanasoft-front/src/services/index.ts +++ b/thanasoft-front/src/services/index.ts @@ -1,2 +1,3 @@ export * from "./http"; export { default as AuthService } from "./auth"; +export { default as WebmailService } from "./webmail"; diff --git a/thanasoft-front/src/services/webmail.ts b/thanasoft-front/src/services/webmail.ts new file mode 100644 index 0000000..e702b2e --- /dev/null +++ b/thanasoft-front/src/services/webmail.ts @@ -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; + 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; + message_uid?: string; +} + +export interface UpdateWebmailPayload { + folder?: string; + status?: string; + is_read?: boolean; + is_starred?: boolean; + subject?: string; + body?: string; + metadata?: Record; +} + +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 { + return request({ + url: "/api/webmail/messages", + method: "get", + params, + }); + }, + + async getMessage(id: number): Promise { + const response = await request({ + url: `/api/webmail/messages/${id}`, + method: "get", + }); + + return response.data; + }, + + async getStats(): Promise { + const response = await request({ + url: "/api/webmail/messages/stats", + method: "get", + }); + + return response.data; + }, + + async sendMessage(payload: SendWebmailPayload): Promise { + const response = await request({ + url: "/api/webmail/messages/send", + method: "post", + data: payload, + }); + + return response.data; + }, + + async getMailboxSettings(): Promise { + const response = await request({ + url: "/api/webmail/settings", + method: "get", + }); + + return response.data; + }, + + async updateMailboxSettings( + payload: UpsertMailboxSettingsPayload + ): Promise { + const response = await request({ + url: "/api/webmail/settings", + method: "put", + data: payload, + }); + + return response.data; + }, + + async syncMailbox(limit = 30): Promise { + const response = await request({ + url: "/api/webmail/messages/sync", + method: "post", + params: { limit }, + }); + + return response.data; + }, + + async syncMailtrap(limit = 30): Promise { + const response = await request({ + url: "/api/webmail/messages/sync-mailtrap", + method: "post", + params: { limit }, + }); + + return response.data; + }, + + async updateMessage( + id: number, + payload: UpdateWebmailPayload + ): Promise { + const response = await request({ + 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; diff --git a/thanasoft-front/src/stores/webmailStore.ts b/thanasoft-front/src/stores/webmailStore.ts new file mode 100644 index 0000000..4d6d27d --- /dev/null +++ b/thanasoft-front/src/stores/webmailStore.ts @@ -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(null); + const messages = ref([]); + const currentMessage = ref(null); + const mailboxSettings = ref(null); + const stats = ref({ ...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 => { + 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;