From c312ec3325688af40e92dab2a5d2118aa442ed2a Mon Sep 17 00:00:00 2001 From: idevakk <219866223+idevakk@users.noreply.github.com> Date: Sat, 28 Feb 2026 23:17:39 +0530 Subject: [PATCH] feat: Prepare Zemailnator for Dokploy deployment - Add highly optimized Dockerfile with Nginx and PHP-FPM 8.4 - Add docker-compose.yml configured with Redis and MariaDB 10.11 - Implement entrypoint.sh and supervisord.conf for background workers - Refactor legacy IMAP scripts into scheduled Artisan Commands - Secure app by removing old routes with hardcoded basic auth credentials - Configure email attachments to use Laravel Storage instead of insecure public/tmp --- .docker/entrypoint.sh | 17 ++ .docker/nginx.conf | 32 ++++ .docker/supervisord.conf | 51 ++++++ Project.md | 76 ++++++++ .../Commands/CleanAttachmentsCommand.php | 33 ++++ app/Console/Commands/CleanMailboxCommand.php | 33 ++++ app/Console/Commands/FetchEmailsCommand.php | 33 ++++ app/Filament/Pages/ImapSettings.php | 23 +-- app/Filament/Pages/ImpersonationLogViewer.php | 11 +- app/Filament/Resources/BlogResource.php | 4 +- app/Filament/Resources/CategoryResource.php | 6 +- app/Filament/Resources/MenuResource.php | 4 +- app/Filament/Resources/PageResource.php | 4 +- .../Tables/PaymentProvidersTable.php | 2 +- app/Filament/Resources/PlanResource.php | 1 + app/Filament/Resources/TicketResource.php | 8 +- .../Schemas/TrialExtensionForm.php | 3 + .../Tables/TrialExtensionsTable.php | 2 +- .../TrialExtensionResource.php | 1 - app/Filament/Widgets/StatsOverview.php | 4 +- app/Helpers/ArrayHelper.php | 4 +- app/Http/Controllers/AppController.php | 7 +- .../Controllers/PaymentCancelController.php | 4 +- app/Http/Middleware/CheckUserBanned.php | 2 +- app/Livewire/Actions/Logout.php | 2 +- app/Livewire/AddOn.php | 19 +- app/Livewire/Dashboard/Dashboard.php | 2 +- app/Livewire/Dashboard/Support.php | 8 +- app/Livewire/Frontend/Email.php | 4 +- app/Livewire/Frontend/Mailbox.php | 1 + app/Livewire/Home.php | 3 +- app/Mail/TicketResponseNotification.php | 4 +- app/Models/Email.php | 39 ++--- app/Models/Log.php | 2 +- app/Models/Message.php | 8 +- app/Models/PremiumEmail.php | 2 +- app/Models/ZEmail.php | 6 +- app/Providers/AppServiceProvider.php | 68 ++++---- .../Payments/Providers/OxapayProvider.php | 2 +- cleanCron.php | 6 +- config/filament-log-viewer.php | 2 +- config/sanctum.php | 2 +- database/factories/TicketResponseFactory.php | 2 +- ...7_135859_5_change_columns_to_long_text.php | 2 +- ...1_17_135900_5_change_link_to_long_text.php | 2 +- ...11_17_140324_create_activity_log_table.php | 4 +- ...add_event_column_to_activity_log_table.php | 4 +- ...atch_uuid_column_to_activity_log_table.php | 4 +- ...umn_to_text_in_payment_providers_table.php | 6 +- ...constraint_remove_single_add_composite.php | 2 +- database/seeders/MetaSeeder.php | 2 +- database/seeders/PaymentProviderSeeder.php | 35 ++-- docker-compose.yml | 61 +++++++ dropmail.php | 74 +------- laravel_webhook_handover.md | 162 ++++++++++++++++++ routes/api.php | 2 +- routes/auth.php | 2 +- routes/console.php | 24 +-- routes/web.php | 70 +------- tests/Concerns/LoadsApplicationData.php | 6 +- tests/Feature/Filament/ResourcesTest.php | 33 ++-- tests/Feature/Filament/UserResourceTest.php | 5 +- tests/Feature/Livewire/Auth/LoginTest.php | 1 + tests/Feature/Livewire/DashboardTest.php | 1 + tests/Feature/Livewire/FrontendTest.php | 6 +- tests/Pest.php | 4 +- tests/Unit/Models/ActivationKeyTest.php | 1 + tests/Unit/Models/BlogTest.php | 6 +- tests/Unit/Models/CategoryTest.php | 2 +- tests/Unit/Models/EmailTest.php | 2 +- tests/Unit/Models/LogTest.php | 1 + tests/Unit/Models/PlanTest.php | 3 +- tests/Unit/Models/PremiumEmailTest.php | 1 + tests/Unit/Models/TicketResponseTest.php | 4 +- tests/Unit/Models/TicketTest.php | 4 +- tests/Unit/Models/UsageLogTest.php | 1 + tests/Unit/Models/UserTest.php | 19 +- tests/Unit/NotifyMeTest.php | 7 +- 78 files changed, 750 insertions(+), 360 deletions(-) create mode 100644 .docker/entrypoint.sh create mode 100644 .docker/nginx.conf create mode 100644 .docker/supervisord.conf create mode 100644 Project.md create mode 100644 app/Console/Commands/CleanAttachmentsCommand.php create mode 100644 app/Console/Commands/CleanMailboxCommand.php create mode 100644 app/Console/Commands/FetchEmailsCommand.php create mode 100644 docker-compose.yml create mode 100644 laravel_webhook_handover.md diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh new file mode 100644 index 0000000..6a7924f --- /dev/null +++ b/.docker/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -e + +echo "Storage symlink..." +php artisan storage:link || true + +echo "Optimize cache..." +php artisan optimize:clear +php artisan config:cache +php artisan route:cache +php artisan view:cache + +echo "Running migrations..." +php artisan migrate --force || true + +# Pass hand off to supervisor +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/.docker/nginx.conf b/.docker/nginx.conf new file mode 100644 index 0000000..9c1717d --- /dev/null +++ b/.docker/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name _; + root /var/www/public; + + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + + index index.php; + + charset utf-8; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + error_page 404 /index.php; + + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_hide_header X-Powered-By; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} diff --git a/.docker/supervisord.conf b/.docker/supervisord.conf new file mode 100644 index 0000000..9715728 --- /dev/null +++ b/.docker/supervisord.conf @@ -0,0 +1,51 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php-fpm] +command=php-fpm -F +autostart=true +autorestart=true +priority=5 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=nginx -g 'daemon off;' +autostart=true +autorestart=true +priority=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:laravel-worker] +process_name=%(program_name)s_%(process_num)02d +command=php /var/www/artisan queue:work --sleep=3 --tries=3 --max-time=3600 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=www-data +numprocs=1 +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 + +[program:laravel-scheduler] +process_name=%(program_name)s_%(process_num)02d +command=php /var/www/artisan schedule:work +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=www-data +numprocs=1 +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 diff --git a/Project.md b/Project.md new file mode 100644 index 0000000..31a868c --- /dev/null +++ b/Project.md @@ -0,0 +1,76 @@ +--- +description: Zemailnator - AI-First Project Documentation +--- + +# Zemailnator Project Documentation + +> **AI INSTRUCTION**: This `Project.md` file contains critical context for the Zemailnator (Disposable Email Generator) application. When interacting with this project, ALWAYS load and review this file as part of your system instructions. Treat these guidelines, architectural details, and known security flaws as your primary working context. + +## 1. Project Overview & Architecture +Zemailnator is a scalable disposable temporary email service (like Mailinator or 10 Minute Mail) built on the Laravel framework. It provides users with instant, disposable email addresses to prevent spam, with premium tiers offering custom domains, 10-minute validity enhancements, and bulk generation capabilities. + +**Core Mechanics:** +- The platform uses IMAP (`ddeboer/imap` via PHP `ext-imap`) to fetch emails from a master mailbox. +- `App\Models\Email::fetchProcessStoreEmail()` processes incoming messages, parses HTML/Text bodies, extracts allowed attachments to `public/tmp/attachments`, and stores the records in the database (or a remote database if `config('app.fetch_from_remote_db')` is true). +- External cron/scripts (like `dropmail.php`) are used to automatically purge old messages and attachments to keep the platform lightweight. + +## 2. Technology Stack +- **Framework:** Laravel 12.x +- **PHP Version:** >= 8.2 +- **Frontend / UI:** Livewire v3, Flux UI Free, Tailwind CSS v4, Vite +- **Admin Panel:** Filament v4 +- **Billing:** Laravel Cashier (Stripe) v15, alongside a custom Unified Payment System (OxaPay, etc.) +- **Testing:** Pest v3 +- **Email Parsing:** PHP `ext-imap`, `ddeboer/imap` + +## 3. Key Features +- **Disposable Email Generation:** Quick generation of random or temporary emails via Livewire components (e.g., `/mailbox`, `/gmailnator`). +- **Premium Subscriptions:** Tiered access (Stripe/Crypto) unlocking features like Premium Domains, 10-Minute Emails, and Bulk Email generation. +- **Admin Dashboard:** Fully managed via Filament, including logs functionality, failed jobs monitoring, and dynamic database configuration. +- **Impersonation:** Admins can impersonate users for troubleshooting (`ImpersonationController`). + +## 4. Known Security Flaws & Vulnerabilities (CRITICAL) +When working on this codebase, prioritize addressing or being mindful of the following security issues: +1. **Hardcoded Credentials in Routes:** + - Found in `routes/web.php` for endpoints `/0xdash/slink` and `/0xdash/scache`. They use basic HTTP authentication with a hardcoded password (`admin@9608`). This is highly insecure and should be migrated to secure Artisan commands via the Filament Admin panel or proper token-based middleware. +2. **Public Attachment Storage:** + - Attachments are stored directly in `public/tmp/attachments`. While there is an extension filter in `Email::fetchProcessStoreEmail()`, any bypass of this filter (e.g., uploading a `.php` file masquerading as another or zero-byte vulnerabilities) can lead to direct Remote Code Execution (RCE) since the directory is publicly accessible. +3. **No Strict Rate Limiting on Generation Endpoints:** + - Temporary email and bulk email endpoints in `routes/web.php` appear to lack specific rate limiting, making the application vulnerable to Denial of Service (DoS) and excessive spam generation. +4. **Standalone PHP Scripts:** + - Scripts like `dropmail.php`, `closeTicket.php`, and `cleanCron.php` inside the public/root directory bootstrap Laravel manually. They lack proper authentication and could potentially be executed repeatedly by unauthorized entities, causing database or IMAP server exhaustion. They should be migrated to Laravel Console Commands (`app/Console/Commands`). + +## 5. Pros & Cons +**Pros:** +- **Modern Stack:** Utilizes bleeding-edge tools (Laravel 12, Livewire 3, Tailwind 4) resulting in a highly responsive SPA-like feel. +- **Robust Admin Panel:** Leveraging Filament provides out-of-the-box CRUD, logging, and mail monitoring. +- **Flexible Monetization:** Mix of traditional Stripe and unified payments (crypto via OxaPay) allows for diverse revenue streams. + +**Cons:** +- **Technical Debt:** Legacy payment routes (`checkout/{plan}`) exist alongside the unified payment system, leading to code fragmentation. +- **Synchronous IMAP Polling:** Running IMAP fetch directly or via sequential cron is slow. It should be offloaded to robust Laravel Queue workers. +- **File Management:** Relying on basic `file_put_contents` for attachments locally limits horizontal scalability (e.g., scaling across multiple Docker pods/servers). S3 or equivalent Object Storage should be used. + +## 6. Development Guide & Setup Best Practices +1. Ensure both `ext-imap` and `ext-curl` are enabled in your `php.ini`. +2. Run `npm run dev` in tandem with `php artisan serve` if working on Frontend/FluxUI adjustments. +3. To test email fetching locally without an active IMAP server, consider writing Pest tests mocking the `ConnectionInterface`. +4. Always use `php artisan test` before committing. The project uses Pest. Make sure datasets and mocking are used properly per the `CLAUDE.md` guidelines. + +## 7. Examples / Workflows + +**Example: Fetching Emails** +The `Email` model has a primary function `fetchProcessStoreEmail()`: +```php +// Call this to manually pull emails +App\Models\Email::fetchProcessStoreEmail(); +``` + +**Example: Reading Emails (Livewire)** +The `Mailbox` component fetches emails for the active user session or session ID, parsing them via: +```php +$emails = App\Models\Email::parseEmail($currentUserEmailAddress, $deletedArray); +``` + +## 8. AI Auto-Load Instruction +**For AI Agents:** Whenever you receive a new chat prompt within this workspace, your implicit first step must be to cross-reference the user's request against this `Project.md` document. Pay special attention to the Laravel 12 / Livewire 3 structures and strictly avoid executing basic auth routes or standard PHP scripts directly if Laravel-native alternatives exist. Use Pest for all tests. ALWAYS rely on the `CLAUDE.md` guidelines as your secondary set of standard rules. diff --git a/app/Console/Commands/CleanAttachmentsCommand.php b/app/Console/Commands/CleanAttachmentsCommand.php new file mode 100644 index 0000000..3a9d014 --- /dev/null +++ b/app/Console/Commands/CleanAttachmentsCommand.php @@ -0,0 +1,33 @@ +info('Starting to clean attachments...'); + Email::deleteBulkAttachments(); + $this->info('Finished cleaning attachments.'); + } +} diff --git a/app/Console/Commands/CleanMailboxCommand.php b/app/Console/Commands/CleanMailboxCommand.php new file mode 100644 index 0000000..98d6f78 --- /dev/null +++ b/app/Console/Commands/CleanMailboxCommand.php @@ -0,0 +1,33 @@ +info('Starting mailbox cleanup...'); + $result = Email::cleanMailbox(); + $this->info($result); + } +} diff --git a/app/Console/Commands/FetchEmailsCommand.php b/app/Console/Commands/FetchEmailsCommand.php new file mode 100644 index 0000000..bb01a66 --- /dev/null +++ b/app/Console/Commands/FetchEmailsCommand.php @@ -0,0 +1,33 @@ +info('Starting to fetch emails from IMAP server...'); + Email::fetchProcessStoreEmail(); + $this->info('Finished fetching emails.'); + } +} diff --git a/app/Filament/Pages/ImapSettings.php b/app/Filament/Pages/ImapSettings.php index b412138..0aa7076 100644 --- a/app/Filament/Pages/ImapSettings.php +++ b/app/Filament/Pages/ImapSettings.php @@ -13,7 +13,6 @@ use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Inerba\DbConfig\AbstractPageSettings; -use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; class ImapSettings extends AbstractPageSettings { @@ -145,7 +144,7 @@ class ImapSettings extends AbstractPageSettings $fields = ['host', 'port', 'username', 'password', 'encryption', 'validate_cert', 'protocol']; foreach ($fields as $field) { - $key = $sectionName . '.' . $field; + $key = $sectionName.'.'.$field; // Try different data structure approaches $value = null; @@ -186,8 +185,8 @@ class ImapSettings extends AbstractPageSettings return [ 'section' => ucfirst($sectionName), 'success' => false, - 'message' => "Missing required fields: " . $missingFields->join(', '), - 'details' => null + 'message' => 'Missing required fields: '.$missingFields->join(', '), + 'details' => null, ]; } @@ -198,7 +197,7 @@ class ImapSettings extends AbstractPageSettings 'section' => ucfirst($sectionName), 'success' => false, 'message' => 'IMAP extension is not loaded in your web server. Please check your Herd PHP configuration or restart your server.', - 'details' => null + 'details' => null, ]; } @@ -210,7 +209,7 @@ class ImapSettings extends AbstractPageSettings 'password' => $config['password'], 'encryption' => $config['encryption'] ?? 'none', 'validate_cert' => $config['validate_cert'] ?? false, - 'protocol' => $config['protocol'] ?? 'imap' + 'protocol' => $config['protocol'] ?? 'imap', ]; // Test connection using the existing ZEmail::connectMailBox method @@ -224,8 +223,8 @@ class ImapSettings extends AbstractPageSettings 'host' => $config['host'], 'port' => $config['port'], 'encryption' => $config['encryption'] ?? 'none', - 'protocol' => $config['protocol'] ?? 'imap' - ] + 'protocol' => $config['protocol'] ?? 'imap', + ], ]; } catch (\Exception $e) { @@ -244,13 +243,12 @@ class ImapSettings extends AbstractPageSettings 'host' => $config['host'] ?? null, 'port' => $config['port'] ?? null, 'encryption' => $config['encryption'] ?? 'none', - 'protocol' => $config['protocol'] ?? 'imap' - ] + 'protocol' => $config['protocol'] ?? 'imap', + ], ]; } } - /** * Send appropriate notification based on test results. */ @@ -294,6 +292,7 @@ class ImapSettings extends AbstractPageSettings $details[] = "{$result['section']}: {$result['details']['messages']} messages"; } } + return implode(' | ', $details); } @@ -306,6 +305,7 @@ class ImapSettings extends AbstractPageSettings foreach ($results as $result) { $details[] = "{$result['section']}: {$result['message']}"; } + return implode(' | ', $details); } @@ -319,6 +319,7 @@ class ImapSettings extends AbstractPageSettings $status = $result['success'] ? '✅' : '❌'; $details[] = "{$status} {$result['section']}"; } + return implode(' | ', $details); } } diff --git a/app/Filament/Pages/ImpersonationLogViewer.php b/app/Filament/Pages/ImpersonationLogViewer.php index c403c3a..7d1f7f0 100644 --- a/app/Filament/Pages/ImpersonationLogViewer.php +++ b/app/Filament/Pages/ImpersonationLogViewer.php @@ -27,7 +27,6 @@ use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Str; use UnitEnum; class ImpersonationLogViewer extends Page implements HasForms, HasTable @@ -132,10 +131,10 @@ class ImpersonationLogViewer extends Page implements HasForms, HasTable TextColumn::make('duration_in_minutes') ->label('Duration') ->formatStateUsing(function ($record) { - return match(true) { - !$record->duration_in_minutes => 'Active', + return match (true) { + ! $record->duration_in_minutes => 'Active', $record->duration_in_minutes < 60 => "{$record->duration_in_minutes}m", - default => round($record->duration_in_minutes / 60, 1) . 'h', + default => round($record->duration_in_minutes / 60, 1).'h', }; }) ->sortable() @@ -324,7 +323,7 @@ class ImpersonationLogViewer extends Page implements HasForms, HasTable ->latest('start_time') ->get(); - $filename = 'impersonation_logs_' . now()->format('Y_m_d_H_i_s') . '.csv'; + $filename = 'impersonation_logs_'.now()->format('Y_m_d_H_i_s').'.csv'; // Create a temporary file $handle = fopen('php://temp', 'r+'); @@ -376,7 +375,7 @@ class ImpersonationLogViewer extends Page implements HasForms, HasTable $filename, [ 'Content-Type' => 'text/csv', - 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', ] ); } diff --git a/app/Filament/Resources/BlogResource.php b/app/Filament/Resources/BlogResource.php index 5f64830..23c15a2 100644 --- a/app/Filament/Resources/BlogResource.php +++ b/app/Filament/Resources/BlogResource.php @@ -2,13 +2,12 @@ namespace App\Filament\Resources; -use BackedEnum; -use UnitEnum; use App\Filament\Resources\BlogResource\Pages\CreateBlog; use App\Filament\Resources\BlogResource\Pages\EditBlog; use App\Filament\Resources\BlogResource\Pages\ListBlogs; use App\Models\Blog; use App\Models\Category; +use BackedEnum; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; @@ -30,6 +29,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Support\Str; +use UnitEnum; class BlogResource extends Resource { diff --git a/app/Filament/Resources/CategoryResource.php b/app/Filament/Resources/CategoryResource.php index 0e30cf4..9ecb466 100644 --- a/app/Filament/Resources/CategoryResource.php +++ b/app/Filament/Resources/CategoryResource.php @@ -2,12 +2,11 @@ namespace App\Filament\Resources; -use BackedEnum; -use UnitEnum; use App\Filament\Resources\CategoryResource\Pages\CreateCategory; use App\Filament\Resources\CategoryResource\Pages\EditCategory; use App\Filament\Resources\CategoryResource\Pages\ListCategories; use App\Models\Category; +use BackedEnum; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; @@ -23,6 +22,7 @@ use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Support\Str; +use UnitEnum; class CategoryResource extends Resource { @@ -60,7 +60,7 @@ class CategoryResource extends Resource TextColumn::make('slug'), TextColumn::make('blogs_count') ->label('Blogs') - ->getStateUsing(fn(Category $record): int => $record->blogs()->count()), + ->getStateUsing(fn (Category $record): int => $record->blogs()->count()), IconColumn::make('is_active')->label('Active')->boolean(), ]) ->filters([ diff --git a/app/Filament/Resources/MenuResource.php b/app/Filament/Resources/MenuResource.php index a792a6e..493bae0 100644 --- a/app/Filament/Resources/MenuResource.php +++ b/app/Filament/Resources/MenuResource.php @@ -2,12 +2,11 @@ namespace App\Filament\Resources; -use BackedEnum; -use UnitEnum; use App\Filament\Resources\MenuResource\Pages\CreateMenu; use App\Filament\Resources\MenuResource\Pages\EditMenu; use App\Filament\Resources\MenuResource\Pages\ListMenus; use App\Models\Menu; +use BackedEnum; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; @@ -21,6 +20,7 @@ use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use UnitEnum; class MenuResource extends Resource { diff --git a/app/Filament/Resources/PageResource.php b/app/Filament/Resources/PageResource.php index c48ba1b..8eb75ef 100644 --- a/app/Filament/Resources/PageResource.php +++ b/app/Filament/Resources/PageResource.php @@ -2,12 +2,11 @@ namespace App\Filament\Resources; -use BackedEnum; -use UnitEnum; use App\Filament\Resources\PageResource\Pages\CreatePage; use App\Filament\Resources\PageResource\Pages\EditPage; use App\Filament\Resources\PageResource\Pages\ListPages; use App\Models\Page; +use BackedEnum; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; @@ -29,6 +28,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Support\Str; +use UnitEnum; class PageResource extends Resource { diff --git a/app/Filament/Resources/PaymentProviders/Tables/PaymentProvidersTable.php b/app/Filament/Resources/PaymentProviders/Tables/PaymentProvidersTable.php index a7d8ee6..c24f078 100644 --- a/app/Filament/Resources/PaymentProviders/Tables/PaymentProvidersTable.php +++ b/app/Filament/Resources/PaymentProviders/Tables/PaymentProvidersTable.php @@ -2,12 +2,12 @@ namespace App\Filament\Resources\PaymentProviders\Tables; +use Filament\Actions\Action; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; -use Filament\Actions\Action; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; diff --git a/app/Filament/Resources/PlanResource.php b/app/Filament/Resources/PlanResource.php index 432195b..790d6fc 100644 --- a/app/Filament/Resources/PlanResource.php +++ b/app/Filament/Resources/PlanResource.php @@ -301,6 +301,7 @@ class PlanResource extends Resource // Halt the bulk deletion process $action->halt(); + return; } } diff --git a/app/Filament/Resources/TicketResource.php b/app/Filament/Resources/TicketResource.php index 18c61ea..beb3f42 100644 --- a/app/Filament/Resources/TicketResource.php +++ b/app/Filament/Resources/TicketResource.php @@ -2,8 +2,6 @@ namespace App\Filament\Resources; -use BackedEnum; -use UnitEnum; use App\Filament\Resources\TicketResource\Pages\CreateTicket; use App\Filament\Resources\TicketResource\Pages\EditTicket; use App\Filament\Resources\TicketResource\Pages\ListTickets; @@ -11,6 +9,7 @@ use App\Filament\Resources\TicketResource\RelationManagers\ResponsesRelationMana use App\Mail\TicketResponseNotification; use App\Models\Ticket; use App\Models\TicketResponse; +use BackedEnum; use Filament\Actions\Action; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; @@ -33,6 +32,7 @@ use Filament\Tables\Table; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Mail; use Illuminate\Support\HtmlString; +use UnitEnum; class TicketResource extends Resource { @@ -120,7 +120,7 @@ class TicketResource extends Resource DatePicker::make('created_from')->label('Created From'), DatePicker::make('created_until')->label('Created Until'), ]) - ->query(fn($query, array $data) => $query + ->query(fn ($query, array $data) => $query ->when($data['created_from'], fn ($query, $date) => $query->whereDate('created_at', '>=', $date)) ->when($data['created_until'], fn ($query, $date) => $query->whereDate('created_at', '<=', $date))), ]) @@ -134,7 +134,7 @@ class TicketResource extends Resource Action::make('view') ->label('View & Respond') ->icon('heroicon-o-eye') - ->schema(fn(Ticket $ticket): array => [ + ->schema(fn (Ticket $ticket): array => [ TextArea::make('response') ->label('Your Response') ->required() diff --git a/app/Filament/Resources/TrialExtensions/Schemas/TrialExtensionForm.php b/app/Filament/Resources/TrialExtensions/Schemas/TrialExtensionForm.php index 8ee5069..3f71ebb 100644 --- a/app/Filament/Resources/TrialExtensions/Schemas/TrialExtensionForm.php +++ b/app/Filament/Resources/TrialExtensions/Schemas/TrialExtensionForm.php @@ -34,6 +34,7 @@ class TrialExtensionForm if ($subscription->trial_ends_at) { $label .= " ({$subscription->trial_ends_at->format('M j, Y')})"; } + return [$subscription->id => $label]; }) ->toArray(); @@ -158,12 +159,14 @@ class TrialExtensionForm if (! $subscriptionId || ! $extensionDays) { $set('new_trial_ends_at', null); + return; } $subscription = Subscription::find($subscriptionId); if (! $subscription) { $set('new_trial_ends_at', null); + return; } diff --git a/app/Filament/Resources/TrialExtensions/Tables/TrialExtensionsTable.php b/app/Filament/Resources/TrialExtensions/Tables/TrialExtensionsTable.php index b0bb3be..20886b8 100644 --- a/app/Filament/Resources/TrialExtensions/Tables/TrialExtensionsTable.php +++ b/app/Filament/Resources/TrialExtensions/Tables/TrialExtensionsTable.php @@ -96,7 +96,7 @@ class TrialExtensionsTable ->label('View Subscription') ->icon('heroicon-o-rectangle-stack') ->color('blue') - ->url(fn ($record) => route('filament.' . filament()->getCurrentPanel()->getId() . '.resources.subscriptions.edit', $record->subscription_id)) + ->url(fn ($record) => route('filament.'.filament()->getCurrentPanel()->getId().'.resources.subscriptions.edit', $record->subscription_id)) ->openUrlInNewTab(), ]) ->toolbarActions([ diff --git a/app/Filament/Resources/TrialExtensions/TrialExtensionResource.php b/app/Filament/Resources/TrialExtensions/TrialExtensionResource.php index e4ab298..593058c 100644 --- a/app/Filament/Resources/TrialExtensions/TrialExtensionResource.php +++ b/app/Filament/Resources/TrialExtensions/TrialExtensionResource.php @@ -7,7 +7,6 @@ use App\Filament\Resources\TrialExtensions\Pages\EditTrialExtension; use App\Filament\Resources\TrialExtensions\Pages\ListTrialExtensions; use App\Filament\Resources\TrialExtensions\Schemas\TrialExtensionForm; use App\Filament\Resources\TrialExtensions\Tables\TrialExtensionsTable; -use App\Models\Subscription; use App\Models\TrialExtension; use BackedEnum; use Filament\Resources\Resource; diff --git a/app/Filament/Widgets/StatsOverview.php b/app/Filament/Widgets/StatsOverview.php index ecdaf95..0e0640e 100644 --- a/app/Filament/Widgets/StatsOverview.php +++ b/app/Filament/Widgets/StatsOverview.php @@ -2,8 +2,6 @@ namespace App\Filament\Widgets; -use Illuminate\Support\Facades\Date; -use Illuminate\Support\Facades\DB; use App\Models\Log; use App\Models\Meta; use App\Models\PremiumEmail; @@ -11,6 +9,8 @@ use App\Models\Ticket; use App\Models\User; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; +use Illuminate\Support\Facades\Date; +use Illuminate\Support\Facades\DB; class StatsOverview extends BaseWidget { diff --git a/app/Helpers/ArrayHelper.php b/app/Helpers/ArrayHelper.php index e74e653..7621c86 100644 --- a/app/Helpers/ArrayHelper.php +++ b/app/Helpers/ArrayHelper.php @@ -14,7 +14,7 @@ class ArrayHelper $keys = explode('.', $key); foreach ($keys as $segment) { - if (!isset($array[$segment])) { + if (! isset($array[$segment])) { return null; } $array = $array[$segment]; @@ -32,7 +32,7 @@ class ArrayHelper ); } catch (\JsonException $e) { // Optional: Log the error - Log::error("JSON encode failed: " . $e->getMessage()); + Log::error('JSON encode failed: '.$e->getMessage()); // Fallback: return empty object instead of crashing return '{}'; diff --git a/app/Http/Controllers/AppController.php b/app/Http/Controllers/AppController.php index 3e9dc20..2f150fd 100644 --- a/app/Http/Controllers/AppController.php +++ b/app/Http/Controllers/AppController.php @@ -2,11 +2,10 @@ namespace App\Http\Controllers; -use Session; -use Illuminate\Routing\Redirector; -use Illuminate\Http\RedirectResponse; use App\Models\Premium; use App\Models\ZEmail; +use Illuminate\Http\RedirectResponse; +use Illuminate\Routing\Redirector; class AppController extends Controller { @@ -56,6 +55,7 @@ class AppController extends Controller return to_route('mailbox'); } + return to_route('home'); } @@ -86,6 +86,7 @@ class AppController extends Controller return to_route('dashboard.premium'); } + return to_route('dashboard'); } diff --git a/app/Http/Controllers/PaymentCancelController.php b/app/Http/Controllers/PaymentCancelController.php index 0d71255..cbb7247 100644 --- a/app/Http/Controllers/PaymentCancelController.php +++ b/app/Http/Controllers/PaymentCancelController.php @@ -18,7 +18,7 @@ class PaymentCancelController extends Controller Log::info('PaymentCancelController: Cancellation page accessed', [ 'user_id' => auth()->id(), - 'session_token' => $sessionToken ? substr($sessionToken, 0, 20) . '...' : 'none', + 'session_token' => $sessionToken ? substr($sessionToken, 0, 20).'...' : 'none', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), ]); @@ -48,4 +48,4 @@ class PaymentCancelController extends Controller 'recentSubscription' => $recentSubscription, ]); } -} \ No newline at end of file +} diff --git a/app/Http/Middleware/CheckUserBanned.php b/app/Http/Middleware/CheckUserBanned.php index 0775480..defa4fe 100644 --- a/app/Http/Middleware/CheckUserBanned.php +++ b/app/Http/Middleware/CheckUserBanned.php @@ -2,9 +2,9 @@ namespace App\Http\Middleware; -use Illuminate\Support\Facades\Auth; use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpFoundation\Response; class CheckUserBanned diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php index bb1e3df..3d7e35d 100644 --- a/app/Livewire/Actions/Logout.php +++ b/app/Livewire/Actions/Logout.php @@ -2,8 +2,8 @@ namespace App\Livewire\Actions; -use Illuminate\Routing\Redirector; use Illuminate\Http\RedirectResponse; +use Illuminate\Routing\Redirector; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Session; diff --git a/app/Livewire/AddOn.php b/app/Livewire/AddOn.php index 48d4d90..c4439cb 100644 --- a/app/Livewire/AddOn.php +++ b/app/Livewire/AddOn.php @@ -2,10 +2,10 @@ namespace App\Livewire; -use Illuminate\Support\Facades\Session; +use App\Models\ZEmail; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; -use App\Models\ZEmail; +use Illuminate\Support\Facades\Session; use Livewire\Component; class AddOn extends Component @@ -31,6 +31,7 @@ class AddOn extends Component if (count($messages['data']) > 0) { return to_route('mailbox'); } + return null; } @@ -48,7 +49,7 @@ class AddOn extends Component $this->faqSchema = [ '@context' => 'https://schema.org', '@type' => 'FAQPage', - 'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ + 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [ '@type' => 'Question', 'name' => $faq['title'], 'acceptedAnswer' => [ @@ -66,7 +67,7 @@ class AddOn extends Component $this->faqSchema = [ '@context' => 'https://schema.org', '@type' => 'FAQPage', - 'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ + 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [ '@type' => 'Question', 'name' => $faq['title'], 'acceptedAnswer' => [ @@ -85,7 +86,7 @@ class AddOn extends Component $this->faqSchema = [ '@context' => 'https://schema.org', '@type' => 'FAQPage', - 'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ + 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [ '@type' => 'Question', 'name' => $faq['title'], 'acceptedAnswer' => [ @@ -103,7 +104,7 @@ class AddOn extends Component $this->faqSchema = [ '@context' => 'https://schema.org', '@type' => 'FAQPage', - 'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ + 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [ '@type' => 'Question', 'name' => $faq['title'], 'acceptedAnswer' => [ @@ -121,7 +122,7 @@ class AddOn extends Component $this->faqSchema = [ '@context' => 'https://schema.org', '@type' => 'FAQPage', - 'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ + 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [ '@type' => 'Question', 'name' => $faq['title'], 'acceptedAnswer' => [ @@ -139,7 +140,7 @@ class AddOn extends Component $this->faqSchema = [ '@context' => 'https://schema.org', '@type' => 'FAQPage', - 'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ + 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [ '@type' => 'Question', 'name' => $faq['title'], 'acceptedAnswer' => [ @@ -157,7 +158,7 @@ class AddOn extends Component $this->faqSchema = [ '@context' => 'https://schema.org', '@type' => 'FAQPage', - 'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ + 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [ '@type' => 'Question', 'name' => $faq['title'], 'acceptedAnswer' => [ diff --git a/app/Livewire/Dashboard/Dashboard.php b/app/Livewire/Dashboard/Dashboard.php index 8f1c03a..257f7f4 100644 --- a/app/Livewire/Dashboard/Dashboard.php +++ b/app/Livewire/Dashboard/Dashboard.php @@ -2,6 +2,7 @@ namespace App\Livewire\Dashboard; +use App\Models\Subscription; use App\Models\UsageLog; use Exception; use Illuminate\Http\Request; @@ -10,7 +11,6 @@ use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Log; use Livewire\Component; use Stripe\StripeClient; -use App\Models\Subscription; class Dashboard extends Component { diff --git a/app/Livewire/Dashboard/Support.php b/app/Livewire/Dashboard/Support.php index d50d0f8..8934d21 100644 --- a/app/Livewire/Dashboard/Support.php +++ b/app/Livewire/Dashboard/Support.php @@ -2,11 +2,11 @@ namespace App\Livewire\Dashboard; -use Illuminate\Support\Str; -use Illuminate\Support\Facades\Request; use App\Models\Ticket; use App\Models\TicketResponse; use Exception; +use Illuminate\Support\Facades\Request; +use Illuminate\Support\Str; use Livewire\Component; class Support extends Component @@ -129,9 +129,9 @@ class Support extends Component public function updateTicketCounts(): void { - $this->open = $this->tickets->filter(fn($ticket): bool => in_array($ticket->status, ['open', 'pending']))->count(); + $this->open = $this->tickets->filter(fn ($ticket): bool => in_array($ticket->status, ['open', 'pending']))->count(); - $this->closed = $this->tickets->filter(fn($ticket): bool => $ticket->status === 'closed')->count(); + $this->closed = $this->tickets->filter(fn ($ticket): bool => $ticket->status === 'closed')->count(); } protected function getClientIp() diff --git a/app/Livewire/Frontend/Email.php b/app/Livewire/Frontend/Email.php index 69a5370..da26b28 100644 --- a/app/Livewire/Frontend/Email.php +++ b/app/Livewire/Frontend/Email.php @@ -2,9 +2,9 @@ namespace App\Livewire\Frontend; -use Illuminate\Routing\Redirector; -use Illuminate\Http\RedirectResponse; use App\Models\ZEmail; +use Illuminate\Http\RedirectResponse; +use Illuminate\Routing\Redirector; use Livewire\Component; class Email extends Component diff --git a/app/Livewire/Frontend/Mailbox.php b/app/Livewire/Frontend/Mailbox.php index 4489999..a0b49b0 100644 --- a/app/Livewire/Frontend/Mailbox.php +++ b/app/Livewire/Frontend/Mailbox.php @@ -40,6 +40,7 @@ class Mailbox extends Component if (! ZEmail::getEmail()) { return to_route('home'); } + return null; } diff --git a/app/Livewire/Home.php b/app/Livewire/Home.php index c29ba2d..8397161 100644 --- a/app/Livewire/Home.php +++ b/app/Livewire/Home.php @@ -2,9 +2,9 @@ namespace App\Livewire; +use App\Models\ZEmail; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; -use App\Models\ZEmail; use Livewire\Component; class Home extends Component @@ -18,6 +18,7 @@ class Home extends Component if (count($messages['data']) > 0) { return to_route('mailbox'); } + return null; } diff --git a/app/Mail/TicketResponseNotification.php b/app/Mail/TicketResponseNotification.php index b6adc72..0a86d91 100644 --- a/app/Mail/TicketResponseNotification.php +++ b/app/Mail/TicketResponseNotification.php @@ -18,9 +18,7 @@ class TicketResponseNotification extends Mailable /** * Create a new message instance. */ - public function __construct(public Ticket $ticket, public Collection $responses) - { - } + public function __construct(public Ticket $ticket, public Collection $responses) {} /** * Get the message envelope. diff --git a/app/Models/Email.php b/app/Models/Email.php index 6fa8011..2f46c92 100644 --- a/app/Models/Email.php +++ b/app/Models/Email.php @@ -2,18 +2,17 @@ namespace App\Models; -use DateTimeImmutable; -use Illuminate\Support\Facades\Date; use App\ColorPicker; use Carbon\CarbonImmutable; use DateTime; +use DateTimeImmutable; use Ddeboer\Imap\ConnectionInterface; use Ddeboer\Imap\Search\Date\Since; use Ddeboer\Imap\Server; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; @@ -78,7 +77,7 @@ class Email extends Model $sender = $message->getFrom(); $date = $message->getDate(); - if (!$date instanceof DateTimeImmutable) { + if (! $date instanceof DateTimeImmutable) { $date = new DateTime; if ($message->getHeaders()->get('udate')) { $date->setTimestamp($message->getHeaders()->get('udate')); @@ -100,11 +99,11 @@ class Email extends Model $obj = []; - $to = $message->getHeaders()->get('To') ? array_map(fn($entry): string => $entry->mailbox.'@'.$entry->host, $message->getHeaders()->get('To')) : []; + $to = $message->getHeaders()->get('To') ? array_map(fn ($entry): string => $entry->mailbox.'@'.$entry->host, $message->getHeaders()->get('To')) : []; - $cc = $message->getHeaders()->get('Cc') ? array_map(fn($entry): string => $entry->mailbox.'@'.$entry->host, $message->getHeaders()->get('Cc')) : []; + $cc = $message->getHeaders()->get('Cc') ? array_map(fn ($entry): string => $entry->mailbox.'@'.$entry->host, $message->getHeaders()->get('Cc')) : []; - $bcc = $message->getHeaders()->get('Bcc') ? array_map(fn($entry): string => $entry->mailbox.'@'.$entry->host, $message->getHeaders()->get('Bcc')) : []; + $bcc = $message->getHeaders()->get('Bcc') ? array_map(fn ($entry): string => $entry->mailbox.'@'.$entry->host, $message->getHeaders()->get('Bcc')) : []; $messageTime = $message->getDate(); $utcTime = CarbonImmutable::instance($messageTime)->setTimezone('UTC')->toDateTimeString(); @@ -127,28 +126,27 @@ class Email extends Model if ($message->hasAttachments()) { $attachments = $message->getAttachments(); - $directory = './tmp/attachments/'.$obj['id'].'/'; + $directoryPath = 'attachments/'.$obj['id']; - if (!is_dir($directory)) { - mkdir($directory, 0777, true); - } foreach ($attachments as $attachment) { $filenameArray = explode('.', (string) $attachment->getFilename()); $extension = $filenameArray[count($filenameArray) - 1]; if (in_array($extension, $allowed)) { - if (! file_exists($directory.$attachment->getFilename())) { + $filePath = $directoryPath.'/'.$attachment->getFilename(); + + if (! \Illuminate\Support\Facades\Storage::disk('public')->exists($filePath)) { try { - file_put_contents( - $directory.$attachment->getFilename(), + \Illuminate\Support\Facades\Storage::disk('public')->put( + $filePath, $attachment->getDecodedContent() ); } catch (Exception $e) { Log::error($e->getMessage()); } - } + if ($attachment->getFilename() !== 'undefined') { - $url = config('app.settings.app_base_url').str_replace('./', '/', $directory.$attachment->getFilename()); + $url = config('app.settings.app_base_url').'/storage/'.$filePath; $structure = $attachment->getStructure(); if (isset($structure->id) && str_contains($obj['content'], trim($structure->id, '<>'))) { $obj['content'] = str_replace('cid:'.trim($structure->id, '<>'), $url, $obj['content']); @@ -159,9 +157,7 @@ class Email extends Model ]; } } - } - } $response['data'][] = $obj; @@ -354,11 +350,9 @@ class Email extends Model public static function deleteBulkAttachments(): void { - $dir = public_path('/tmp/attachments'); - try { - if (File::exists($dir)) { - File::cleanDirectory($dir); + if (\Illuminate\Support\Facades\Storage::disk('public')->exists('attachments')) { + \Illuminate\Support\Facades\Storage::disk('public')->deleteDirectory('attachments'); } } catch (Exception $e) { Log::error($e->getMessage()); @@ -431,6 +425,7 @@ class Email extends Model $currentTime = Date::now('UTC'); $lastRecordTime = Date::parse($latestRecord->timestamp); + return $lastRecordTime->diffInMinutes($currentTime) < 5; } diff --git a/app/Models/Log.php b/app/Models/Log.php index e5577c0..d42ec84 100644 --- a/app/Models/Log.php +++ b/app/Models/Log.php @@ -2,9 +2,9 @@ namespace App\Models; -use Illuminate\Support\Facades\Date; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Date; class Log extends Model { diff --git a/app/Models/Message.php b/app/Models/Message.php index 2dc98eb..60801f7 100644 --- a/app/Models/Message.php +++ b/app/Models/Message.php @@ -2,10 +2,10 @@ namespace App\Models; -use DateTimeImmutable; use App\ColorPicker; use Carbon\Carbon; use DateTime; +use DateTimeImmutable; use Ddeboer\Imap\Search\Email\Cc; use Ddeboer\Imap\Search\Email\To; use Ddeboer\Imap\SearchExpression; @@ -41,7 +41,7 @@ class Message extends Model $message->attachments = $request->get('attachment-info'); $message->save(); $directory = './attachments/'.$message->id; - if (!is_dir($directory)) { + if (! is_dir($directory)) { mkdir($directory, 0777, true); } $attachment_ids = json_decode((string) $request->get('attachment-info')); @@ -146,7 +146,7 @@ class Message extends Model $blocked = false; $sender = $message->getFrom(); $date = $message->getDate(); - if (!$date instanceof DateTimeImmutable) { + if (! $date instanceof DateTimeImmutable) { $date = new DateTime; if ($message->getHeaders()->get('udate')) { $date->setTimestamp($message->getHeaders()->get('udate')); @@ -193,7 +193,7 @@ class Message extends Model if ($message->hasAttachments() && ! $blocked) { $attachments = $message->getAttachments(); $directory = './tmp/attachments/'.$obj['id'].'/'; - if (!is_dir($directory)) { + if (! is_dir($directory)) { mkdir($directory, 0777, true); } foreach ($attachments as $attachment) { diff --git a/app/Models/PremiumEmail.php b/app/Models/PremiumEmail.php index efb97dd..8b7b13d 100644 --- a/app/Models/PremiumEmail.php +++ b/app/Models/PremiumEmail.php @@ -2,11 +2,11 @@ namespace App\Models; -use Illuminate\Support\Facades\Date; use App\ColorPicker; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Validator; class PremiumEmail extends Model diff --git a/app/Models/ZEmail.php b/app/Models/ZEmail.php index 71de870..fe27c47 100644 --- a/app/Models/ZEmail.php +++ b/app/Models/ZEmail.php @@ -2,9 +2,9 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Ddeboer\Imap\ConnectionInterface; use Ddeboer\Imap\Server; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cookie; @@ -14,6 +14,7 @@ class ZEmail extends Model { use HasFactory; use HasFactory; + public static function connectMailBox($imap = null): ConnectionInterface { if ($imap === null) { @@ -38,6 +39,7 @@ class ZEmail extends Model if (Email::mailToDBStatus()) { return Email::parseEmail($email, $deleted); } + return Message::fetchMessages($email, $type, $deleted); } @@ -57,6 +59,7 @@ class ZEmail extends Model if (Cookie::has('email')) { return Cookie::get('email'); } + return $generate ? ZEmail::generateRandomEmail() : null; } @@ -65,6 +68,7 @@ class ZEmail extends Model if (Cookie::has('emails')) { return unserialize(Cookie::get('emails')); } + return []; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6164162..48acec5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -81,7 +81,7 @@ class AppServiceProvider extends ServiceProvider $this->appConfig = [ 'website_settings' => [], 'imap_settings' => [], - 'configuration_settings' => [] + 'configuration_settings' => [], ]; Log::error($e->getMessage()); } @@ -114,40 +114,40 @@ class AppServiceProvider extends ServiceProvider private function loadLegacySettings(): array { return [ - "app_name" => $this->getConfig('website_settings.app_name'), - "app_version" => $this->getConfig('website_settings.app_version'), - "app_base_url" => $this->getConfig('website_settings.app_base_url'), - "app_admin" => $this->getConfig('website_settings.app_admin'), - "app_title" => $this->getConfig('website_settings.app_title'), - "app_description" => $this->getConfig('website_settings.app_description'), - "app_keywords" => $this->getConfig('website_settings.app_keywords'), - "app_contact" => $this->getConfig('website_settings.app_contact'), - "app_meta" => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.app_meta')), - "app_social" => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.app_social')), - "app_header" => $this->getConfig('website_settings.app_header'), - "app_footer" => $this->getConfig('website_settings.app_footer'), - "imap_settings" => ArrayHelper::jsonEncodeSafe([ - "host" => $this->getConfig('imap_settings.public.host'), - "port" => $this->getConfig('imap_settings.public.port'), - "username" => $this->getConfig('imap_settings.public.username'), - "password" => $this->getConfig('imap_settings.public.password'), - "encryption" => $this->getConfig('imap_settings.public.encryption'), - "validate_cert" => $this->getConfig('imap_settings.public.validate_cert'), - "default_account" => $this->getConfig('imap_settings.public.default_account'), - "protocol" => $this->getConfig('imap_settings.public.protocol'), - "cc_check" => $this->getConfig('imap_settings.public.cc_check'), - "premium_host" => $this->getConfig('imap_settings.premium.host'), - "premium_port" => $this->getConfig('imap_settings.premium.port'), - "premium_username" => $this->getConfig('imap_settings.premium.username'), - "premium_password" => $this->getConfig('imap_settings.premium.password'), - "premium_encryption" => $this->getConfig('imap_settings.premium.premium_encryption'), - "premium_validate_cert" => $this->getConfig('imap_settings.premium.validate_cert'), - "premium_default_account" => $this->getConfig('imap_settings.premium.default_account'), - "premium_protocol" => $this->getConfig('imap_settings.premium.protocol'), - "premium_cc_check" => $this->getConfig('imap_settings.premium.cc_check'), + 'app_name' => $this->getConfig('website_settings.app_name'), + 'app_version' => $this->getConfig('website_settings.app_version'), + 'app_base_url' => $this->getConfig('website_settings.app_base_url'), + 'app_admin' => $this->getConfig('website_settings.app_admin'), + 'app_title' => $this->getConfig('website_settings.app_title'), + 'app_description' => $this->getConfig('website_settings.app_description'), + 'app_keywords' => $this->getConfig('website_settings.app_keywords'), + 'app_contact' => $this->getConfig('website_settings.app_contact'), + 'app_meta' => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.app_meta')), + 'app_social' => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.app_social')), + 'app_header' => $this->getConfig('website_settings.app_header'), + 'app_footer' => $this->getConfig('website_settings.app_footer'), + 'imap_settings' => ArrayHelper::jsonEncodeSafe([ + 'host' => $this->getConfig('imap_settings.public.host'), + 'port' => $this->getConfig('imap_settings.public.port'), + 'username' => $this->getConfig('imap_settings.public.username'), + 'password' => $this->getConfig('imap_settings.public.password'), + 'encryption' => $this->getConfig('imap_settings.public.encryption'), + 'validate_cert' => $this->getConfig('imap_settings.public.validate_cert'), + 'default_account' => $this->getConfig('imap_settings.public.default_account'), + 'protocol' => $this->getConfig('imap_settings.public.protocol'), + 'cc_check' => $this->getConfig('imap_settings.public.cc_check'), + 'premium_host' => $this->getConfig('imap_settings.premium.host'), + 'premium_port' => $this->getConfig('imap_settings.premium.port'), + 'premium_username' => $this->getConfig('imap_settings.premium.username'), + 'premium_password' => $this->getConfig('imap_settings.premium.password'), + 'premium_encryption' => $this->getConfig('imap_settings.premium.premium_encryption'), + 'premium_validate_cert' => $this->getConfig('imap_settings.premium.validate_cert'), + 'premium_default_account' => $this->getConfig('imap_settings.premium.default_account'), + 'premium_protocol' => $this->getConfig('imap_settings.premium.protocol'), + 'premium_cc_check' => $this->getConfig('imap_settings.premium.cc_check'), ]), - "configuration_settings" => ArrayHelper::jsonEncodeSafe($this->appConfig['configuration_settings']), - "ads_settings" => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.ads_settings')), + 'configuration_settings' => ArrayHelper::jsonEncodeSafe($this->appConfig['configuration_settings']), + 'ads_settings' => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.ads_settings')), ]; } diff --git a/app/Services/Payments/Providers/OxapayProvider.php b/app/Services/Payments/Providers/OxapayProvider.php index 83d08ad..fe1c5cb 100644 --- a/app/Services/Payments/Providers/OxapayProvider.php +++ b/app/Services/Payments/Providers/OxapayProvider.php @@ -35,7 +35,7 @@ class OxapayProvider implements PaymentProviderContract $config = array_merge($dbConfig, $config); $this->config = $config; - $this->sandbox = $config['sandbox'] === "true" ?? false; + $this->sandbox = $config['sandbox'] === 'true' ?? false; $this->merchantApiKey = $this->sandbox ? ($config['sandbox_merchant_api_key'] ?? '') : ($config['merchant_api_key'] ?? ''); $this->baseUrl = 'https://api.oxapay.com/v1'; diff --git a/cleanCron.php b/cleanCron.php index 6b6a0ab..b085649 100644 --- a/cleanCron.php +++ b/cleanCron.php @@ -10,13 +10,13 @@ $app = require_once __DIR__.'/bootstrap/app.php'; $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class); try { - // Run the Artisan command 'ping' - $exitCode = $kernel->call('cleanMail'); + // Run the Artisan command + $exitCode = $kernel->call('mailbox:clean'); // Get the output of the command $output = $kernel->output(); - echo "Artisan command 'schedule:run' executed successfully. Exit code: $exitCode\n"; + echo "Artisan command executed successfully. Exit code: $exitCode\n"; echo "Output:\n$output"; } catch (\Exception $e) { diff --git a/config/filament-log-viewer.php b/config/filament-log-viewer.php index f6ba71a..64a77e2 100644 --- a/config/filament-log-viewer.php +++ b/config/filament-log-viewer.php @@ -45,7 +45,7 @@ return [ 'pattern' => [ 'prefix' => 'laravel-', 'date' => '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]', - 'extension' => '.log' + 'extension' => '.log', ], /* ----------------------------------------------------------------- diff --git a/config/sanctum.php b/config/sanctum.php index 37a4133..2b5f44e 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -1,8 +1,8 @@ longText('html')->nullable()->change(); $table->longText('text')->nullable()->change(); }); - + } public function down() diff --git a/database/migrations/2025_11_17_135900_5_change_link_to_long_text.php b/database/migrations/2025_11_17_135900_5_change_link_to_long_text.php index f8c3ad4..5c7a615 100644 --- a/database/migrations/2025_11_17_135900_5_change_link_to_long_text.php +++ b/database/migrations/2025_11_17_135900_5_change_link_to_long_text.php @@ -11,7 +11,7 @@ return new class extends Migration Schema::table(config('mails.database.tables.events', 'mail_events'), function (Blueprint $table) { $table->longText('link')->nullable()->change(); }); - + } public function down() diff --git a/database/migrations/2025_11_17_140324_create_activity_log_table.php b/database/migrations/2025_11_17_140324_create_activity_log_table.php index 7c05bc8..b788f65 100644 --- a/database/migrations/2025_11_17_140324_create_activity_log_table.php +++ b/database/migrations/2025_11_17_140324_create_activity_log_table.php @@ -1,8 +1,8 @@ conname)) { + if (! empty($constraint->conname)) { DB::statement("ALTER TABLE payment_providers DROP CONSTRAINT {$constraint->conname};"); } } catch (\Throwable $e) { diff --git a/database/migrations/2025_12_09_180756_update_usernames_unique_constraint_remove_single_add_composite.php b/database/migrations/2025_12_09_180756_update_usernames_unique_constraint_remove_single_add_composite.php index ac4ebfd..9c660c3 100644 --- a/database/migrations/2025_12_09_180756_update_usernames_unique_constraint_remove_single_add_composite.php +++ b/database/migrations/2025_12_09_180756_update_usernames_unique_constraint_remove_single_add_composite.php @@ -34,4 +34,4 @@ return new class extends Migration $table->unique('username', 'usernames_username_unique'); }); } -}; \ No newline at end of file +}; diff --git a/database/seeders/MetaSeeder.php b/database/seeders/MetaSeeder.php index 075e364..bb963a3 100644 --- a/database/seeders/MetaSeeder.php +++ b/database/seeders/MetaSeeder.php @@ -2,9 +2,9 @@ namespace Database\Seeders; -use stdClass; use App\Models\Meta; use Illuminate\Database\Seeder; +use stdClass; class MetaSeeder extends Seeder { diff --git a/database/seeders/PaymentProviderSeeder.php b/database/seeders/PaymentProviderSeeder.php index 0665ef3..9771d24 100644 --- a/database/seeders/PaymentProviderSeeder.php +++ b/database/seeders/PaymentProviderSeeder.php @@ -28,15 +28,15 @@ class PaymentProviderSeeder extends Seeder 'secret_key' => env('STRIPE_SECRET') ?: 'sk_test_placeholder', 'publishable_key' => env('STRIPE_PUBLISHABLE_KEY') ?: 'pk_test_placeholder', 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET') ?: 'whsec_placeholder', - 'webhook_url' => env('APP_URL', 'https://example.com') . '/webhook/stripe', - 'success_url' => env('APP_URL', 'https://example.com') . '/payment/success', - 'cancel_url' => env('APP_URL', 'https://example.com') . '/payment/cancel', + 'webhook_url' => env('APP_URL', 'https://example.com').'/webhook/stripe', + 'success_url' => env('APP_URL', 'https://example.com').'/payment/success', + 'cancel_url' => env('APP_URL', 'https://example.com').'/payment/cancel', 'currency' => env('CASHIER_CURRENCY', 'USD'), ], 'supports_recurring' => true, 'supports_one_time' => true, 'supported_currencies' => [ - 'USD' => 'US Dollar' + 'USD' => 'US Dollar', ], 'fee_structure' => [ 'fixed_fee' => '0.50', @@ -56,14 +56,14 @@ class PaymentProviderSeeder extends Seeder 'api_key' => env('LEMON_SQUEEZY_API_KEY', 'lsk_...'), 'store_id' => env('LEMON_SQUEEZY_STORE_ID', '...'), 'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET', 'whsec_...'), - 'webhook_url' => env('APP_URL') . '/webhook/lemon-squeezy', - 'success_url' => env('APP_URL') . '/payment/success', - 'cancel_url' => env('APP_URL') . '/payment/cancel', + 'webhook_url' => env('APP_URL').'/webhook/lemon-squeezy', + 'success_url' => env('APP_URL').'/payment/success', + 'cancel_url' => env('APP_URL').'/payment/cancel', ], 'supports_recurring' => true, 'supports_one_time' => true, 'supported_currencies' => [ - 'USD' => 'US Dollar' + 'USD' => 'US Dollar', ], 'fee_structure' => [ 'fixed_fee' => '0.50', @@ -86,9 +86,9 @@ class PaymentProviderSeeder extends Seeder 'sandbox_api_key' => env('POLAR_SANDBOX_API_KEY', 'pol_test_...'), 'sandbox_webhook_secret' => env('POLAR_SANDBOX_WEBHOOK_SECRET', 'whsec_test_...'), 'access_token' => env('POLAR_ACCESS_TOKEN', 'polar_...'), - 'webhook_url' => env('APP_URL') . '/webhook/polar', - 'success_url' => env('APP_URL') . '/payment/success', - 'cancel_url' => env('APP_URL') . '/payment/cancel', + 'webhook_url' => env('APP_URL').'/webhook/polar', + 'success_url' => env('APP_URL').'/payment/success', + 'cancel_url' => env('APP_URL').'/payment/cancel', ], 'supports_recurring' => true, 'supports_one_time' => true, @@ -115,9 +115,9 @@ class PaymentProviderSeeder extends Seeder 'sandbox_merchant_api_key' => env('OXAPAY_SANDBOX_MERCHANT_API_KEY', 'merchant_sb_...'), 'payout_api_key' => env('OXAPAY_PAYOUT_API_KEY', 'payout_...'), 'sandbox_payout_api_key' => env('OXAPAY_SANDBOX_PAYOUT_API_KEY', 'payout_sb_...'), - 'callback_url' => env('OXAPAY_CALLBACK_URL', env('APP_URL') . '/webhook/oxapay'), - 'success_url' => env('APP_URL') . '/payment/success', - 'cancel_url' => env('APP_URL') . '/payment/cancel', + 'callback_url' => env('OXAPAY_CALLBACK_URL', env('APP_URL').'/webhook/oxapay'), + 'success_url' => env('APP_URL').'/payment/success', + 'cancel_url' => env('APP_URL').'/payment/cancel', 'sandbox' => env('OXAPAY_SANDBOX', true), 'currency' => env('OXAPAY_CURRENCY', 'USD'), // string 'lifetime' => env('OXAPAY_LIFETIME', 30), // integer · min: 15 · max: 2880 @@ -157,9 +157,9 @@ class PaymentProviderSeeder extends Seeder 'exchange_rate_provider' => env('CRYPTO_EXCHANGE_RATE_PROVIDER', 'coingecko'), 'coingecko_api_key' => env('COINGECKO_API_KEY'), 'blockchair_api_key' => env('BLOCKCHAIR_API_KEY'), - 'webhook_url' => env('APP_URL') . '/webhook/crypto', - 'success_url' => env('APP_URL') . '/payment/success', - 'cancel_url' => env('APP_URL') . '/payment/cancel', + 'webhook_url' => env('APP_URL').'/webhook/crypto', + 'success_url' => env('APP_URL').'/payment/success', + 'cancel_url' => env('APP_URL').'/payment/cancel', 'supported_wallets' => [ 'btc' => ['bitcoin', 'lightning'], 'eth' => ['ethereum', 'erc20'], @@ -227,6 +227,7 @@ class PaymentProviderSeeder extends Seeder } } catch (\Exception $e) { $this->command->error("❌ Error seeding provider {$providerData['name']}: {$e->getMessage()}"); + continue; } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..976078f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "${APP_PORT:-8000}:80" + environment: + - APP_ENV=${APP_ENV:-production} + - APP_DEBUG=${APP_DEBUG:-false} + - APP_URL=${APP_URL} + - DB_CONNECTION=${DB_CONNECTION:-mysql} + - DB_HOST=${DB_HOST:-mariadb} + - DB_PORT=${DB_PORT:-3306} + - DB_DATABASE=${DB_DATABASE:-zemail} + - DB_USERNAME=${DB_USERNAME:-zemail_user} + - DB_PASSWORD=${DB_PASSWORD:-secret} + - CACHE_STORE=${CACHE_STORE:-redis} + - QUEUE_CONNECTION=${QUEUE_CONNECTION:-redis} + - SESSION_DRIVER=${SESSION_DRIVER:-redis} + - REDIS_HOST=${REDIS_HOST:-redis} + - REDIS_PASSWORD=${REDIS_PASSWORD:-null} + - REDIS_PORT=${REDIS_PORT:-6379} + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + volumes: + - app-storage:/var/www/storage + - app-public-tmp:/var/www/public/tmp + depends_on: + - redis + - mariadb + + redis: + image: redis:alpine + restart: unless-stopped + volumes: + - redis-data:/data + + mariadb: + image: mariadb:10.11 + restart: unless-stopped + environment: + - MYSQL_DATABASE=${DB_DATABASE:-zemail} + - MYSQL_USER=${DB_USERNAME:-zemail_user} + - MYSQL_PASSWORD=${DB_PASSWORD:-secret} + - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-rootsecret} + volumes: + - mariadb-data:/var/lib/mysql + +volumes: + app-storage: + app-public-tmp: + redis-data: + mariadb-data: diff --git a/dropmail.php b/dropmail.php index 883b6b5..36f9c35 100644 --- a/dropmail.php +++ b/dropmail.php @@ -11,71 +11,11 @@ $kernel->bootstrap(); set_time_limit(0); -$newTimezone = 'Europe/London'; -date_default_timezone_set($newTimezone); - -$imapDB = json_decode(config('app.settings.imap_settings') ?: '{}', true); - -// Mailbox credentials -$hostname = '{'.($imapDB['host'] ?? 'localhost').':'.($imapDB['port'] ?? '993').'/ssl}INBOX'; -$username = $imapDB['username'] ?? ''; -$password = $imapDB['password'] ?? ''; - -// Connect to mailbox -$inbox = imap_open($hostname, $username, $password); - -// Check for connection errors -if (! $inbox) { - exit('Could not connect to mailbox: '.imap_last_error()); +try { + // Run the new Artisan command fallback + $exitCode = $kernel->call('mailbox:clean'); + $output = $kernel->output(); + echo $output; +} catch (\Exception $e) { + echo 'Error running Artisan command: '.$e->getMessage(); } - -// Get current time in Unix timestamp -$current_time = time(); - -// Search for messages older than one day -// $search_criteria = 'BEFORE "' . date('d-M-Y', strtotime('-3 hours', $current_time)) . '"'; -// $messages = imap_search($inbox, $search_criteria); - -$messages = imap_search($inbox, 'ALL'); - -$batch_size = 10; -$deleted_count = 0; - -// if ($messages) { -// $chunks = array_chunk($messages, $batch_size); -// foreach ($chunks as $chunk) { -// foreach ($chunk as $message_number) { -// imap_delete($inbox, $message_number); -// } -// imap_expunge($inbox); -// $deleted_count += count($chunk); -// } -// echo $deleted_count . ' messages older than specified time have been deleted.'; -// } else { -// echo 'No messages older than specified time found in mailbox.'; -// } - -if ($messages) { - $chunks = array_chunk($messages, $batch_size); - foreach ($chunks as $chunk) { - foreach ($chunk as $message_number) { - // Get message header to fetch internal date - $header = imap_headerinfo($inbox, $message_number); - $date_str = $header->date; - $msg_time = strtotime($date_str); - - // Check if message is older than 3 hours - if ($msg_time !== false && ($current_time - $msg_time) > 2 * 3600) { - imap_delete($inbox, $message_number); - $deleted_count++; - } - } - imap_expunge($inbox); - } - echo $deleted_count.' messages older than 2 hours have been deleted.'; -} else { - echo 'No messages found in mailbox.'; -} - -// Close mailbox connection -imap_close($inbox); diff --git a/laravel_webhook_handover.md b/laravel_webhook_handover.md new file mode 100644 index 0000000..daad4f1 --- /dev/null +++ b/laravel_webhook_handover.md @@ -0,0 +1,162 @@ +# MailOps Webhook Handover Document + +This document provides the exact specifications needed to implement the receiving end of the MailOps email synchronization system within the Laravel application. + +## 1. Webhook Endpoint Specification + +The MailOps worker will push new emails to this exact endpoint on your Laravel server: + +* **URL:** `POST https://your-laravel-app.com/api/webhooks/incoming_email` +* **Headers:** + * `Content-Type: application/json` + * `Authorization: Bearer ` (You must configure this secret in both MailOps and Laravel). + +### A. Expected JSON Payload (With Attachments) + +```json +{ + "hash": "a1b2c3d4e5f6g7h8i9j0...", + "metadata": { + "hash": "a1b2c3d4e5f6g7h8i9j0...", + "recipientEmail": "user@example.com", + "recipientName": "John Doe", + "senderEmail": "alert@service.com", + "senderName": "Service Alerts", + "domain": "example.com", + "subject": "Important Notification", + "received_at": "2026-02-26T17:35:00Z", + "attachments": [ + { + "filename": "invoice.pdf", + "mimeType": "application/pdf", + "size": 102400, + "s3_path": "mail-attachments/2026/02/26/hash_invoice.pdf" + } + ], + "attachmentSize": 102400 + }, + "bodyText": "Plain text content...", + "bodyHtml": "HTML content..." +} +``` +*(Note: `received_at` is in ISO 8601 format ending with `Z` to explicitly denote UTC. `bodyHtml` and `bodyText` are completely separated from the metadata to optimize database payload sizes).* + +### B. Expected JSON Payload (NO Attachments) +When an email has no attachments, the `attachments` array will be empty and `attachmentSize` will be zero. Also, depending on the email client, `bodyHtml` or `bodyText` might be `null`. + +```json +{ + "hash": "b2c3d4e5f6g7h8i9j0a1...", + "metadata": { + "hash": "b2c3d4e5f6g7h8i9j0a1...", + "recipientEmail": "user@example.com", + "recipientName": "", + "senderEmail": "friend@service.com", + "senderName": "Friend", + "domain": "example.com", + "subject": "Quick Question", + "received_at": "2026-02-26T17:38:12Z", + "attachments": [], + "attachmentSize": 0 + }, + "bodyText": "Hey, are we still fast approaching the deadline?", + "bodyHtml": null +} +``` + +--- + +## 2. Laravel Implementation Checklist + +When you switch to the Laravel project, you need to build the following: + +### Step 1: Route & Middleware +Define the API route and protect it with a simple Bearer token check. +```php +// routes/api.php +Route::post('/webhooks/incoming_email', [EmailWebhookController::class, 'handle']) + ->middleware('verify.webhook.secret'); +``` + +### Step 2: The Controller +The controller persists the metadata to MariaDB and the heavy body to MongoDB. **Crucially**, it also checks if the MongoDB TTL index exists, and if not, automatically creates it using the value defined in your Laravel `.env` file (e.g., `EMAIL_BODY_TTL_SECONDS=259200`). + +```php +// app/Http/Controllers/EmailWebhookController.php +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Cache; + +public function handle(Request $request) +{ + $payload = $request->all(); + $meta = $payload['metadata']; + $hash = $payload['hash']; + + // 1. Auto-Setup MongoDB TTL Index (Executes only once via Cache) + $this->ensureMongoTtlIndexExists(); + + // 2. MariaDB: Save Metadata + Email::updateOrCreate( + ['unique_id_hash' => $hash], + [ + 'recipient_email' => $meta['recipientEmail'], + 'sender_email' => $meta['senderEmail'], + 'subject' => $meta['subject'] ?? '', + 'is_read' => false, + // Parse the ISO 8601 UTC timestamp format explicitly for SQL + 'received_at' => Carbon::parse($meta['received_at'])->setTimezone('UTC')->toDateTimeString(), + // Store attachments JSON. If empty, ensure it's saved as an empty array '[]' + 'attachments' => !empty($meta['attachments']) ? json_encode($meta['attachments']) : '[]', + 'attachment_size' => $meta['attachmentSize'] ?? 0 + ] + ); + + // 3. MongoDB: Save the heavy body with TTL + // Assuming you have the jenssegers/mongodb package installed + RecentEmailBody::updateOrCreate( + ['unique_id_hash' => $hash], + [ + // Handle cases where the sender only sends Text or only HTML + 'body_text' => $payload['bodyText'] ?? '', + 'body_html' => $payload['bodyHtml'] ?? '', + 'created_at' => new \MongoDB\BSON\UTCDateTime(now()->timestamp * 1000), // BSON required for TTL + ] + ); + + return response()->json(['status' => 'success'], 200); +} + +/** + * Ensures the TTL index is created on the MongoDB collection. + * Uses Laravel Cache to avoid checking the database on every single webhook. + */ +private function ensureMongoTtlIndexExists() +{ + Cache::rememberForever('mongo_ttl_index_created', function () { + // Fetch TTL from Laravel .env (Default: 72 hours / 259200 seconds) + $ttlSeconds = (int) env('EMAIL_BODY_TTL_SECONDS', 259200); + + $collection = DB::connection('mongodb')->getCollection('recent_email_bodies'); + + // Background creation prevents locking the database during webhook execution + $collection->createIndex( + ['created_at' => 1], + [ + 'expireAfterSeconds' => $ttlSeconds, + 'background' => true, + 'name' => 'ttl_created_at_index' // Named index prevents duplicate recreation errors + ] + ); + + return true; + }); +} +``` + +--- + +## 3. Resiliency Notes + +* **Idempotency:** The MailOps worker might retry a webhook if a network timeout occurs even after Laravel successfully saved it. Your Laravel code MUST use `updateOrCreate` or `INSERT IGNORE` (like the example above) so it doesn't create duplicate emails if the same payload hash is received twice. +* **Timeouts:** The MailOps worker expects a response within 5 to 10 seconds. Do not perform long-running synchronous tasks (like connecting to external APIs or sending heavy push notifications) inside the webhook controller. Dispatch those to a Laravel Queue instead. diff --git a/routes/api.php b/routes/api.php index a54cdb6..1e071cb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,4 +3,4 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; -Route::get('/user', fn(Request $request) => $request->user())->middleware('auth:sanctum'); +Route::get('/user', fn (Request $request) => $request->user())->middleware('auth:sanctum'); diff --git a/routes/auth.php b/routes/auth.php index 865ea61..e2a45e8 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,7 +1,7 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); -// Schedule::call(function () { -// Email::fetchProcessStoreEmail(); -// })->everyMinute(); - -Schedule::call(function (): void { - Email::deleteBulkAttachments(); -})->daily(); - -// Schedule::call(function () { -// Email::deleteBulkMailboxes(); -// })->everyMinute(); +// Schedule the new commands instead of closures +Schedule::command('emails:fetch')->everyMinute(); +Schedule::command('attachments:clean')->daily(); +Schedule::command('mailbox:clean')->everyTwoHours(); +// Keep other necessary schedules Schedule::call(function (): void { Email::deleteMessagesFromDB(); })->everyTwoHours(); @@ -30,10 +25,7 @@ Schedule::call(function (): void { Log::deleteLogsFromDB(); })->everyThreeHours(); -Schedule::call(function (): void { - Email::cleanMailbox(); -}); - +// Preserve existing commands Artisan::command('cleanMail', function (): void { $this->comment(Email::cleanMailbox()); }); diff --git a/routes/web.php b/routes/web.php index 6f575ea..f7de1c0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,6 @@ name('home'); Route::get('/mailbox', Mailbox::class)->name('mailbox'); Route::get('/mailbox/{email?}', [AppController::class, 'mailbox'])->name('mailboxFromURL'); @@ -71,76 +73,12 @@ Route::middleware(['auth', 'verified', CheckUserBanned::class])->group(function Route::get('dashboard/compose-email', Dashboard::class)->name('dashboard.compose'); Route::get('dashboard/support', Support::class)->name('dashboard.support'); - // LEGACY: Old Stripe Cashier checkout route (deprecated - use unified payment system) - Route::get('checkout/{plan}', function ($pricing_id) { - $plans = config('app.plans'); - $pricingData = []; - foreach ($plans as $plan) { - $pricingData[] = $plan['pricing_id']; - } - - if (in_array($pricing_id, $pricingData)) { - return auth()->user() - ->newSubscription('default', $pricing_id) - ->allowPromotionCodes() - ->checkout([ - 'billing_address_collection' => 'required', - 'success_url' => route('checkout.success'), - 'cancel_url' => route('checkout.cancel'), - ]); - - } - abort(404); - })->name('checkout'); - // LEGACY: Payment status routes (used by both legacy and unified systems) Route::get('dashboard/success', [Dashboard::class, 'paymentStatus'])->name('checkout.success')->defaults('status', 'success'); Route::get('dashboard/cancel', [Dashboard::class, 'paymentStatus'])->name('checkout.cancel')->defaults('status', 'cancel'); Route::get('dashboard/billing', fn () => auth()->user()->redirectToBillingPortal(route('dashboard')))->name('billing'); - Route::get('0xdash/slink', function (Request $request) { - $validUser = 'admin'; - $validPass = 'admin@9608'; // 🔐 Change this to something secure - - if (! isset($_SERVER['PHP_AUTH_USER']) || - Request::server('PHP_AUTH_USER') !== $validUser || - Request::server('PHP_AUTH_PW') !== $validPass) { - - header('WWW-Authenticate: Basic realm="Restricted Area"'); - header('HTTP/1.0 401 Unauthorized'); - echo 'Unauthorized'; - exit; - } - Artisan::call('storage:link'); - $output = Artisan::output(); - - return response()->json([ - 'message' => trim($output), - ]); - })->name('storageLink'); - - Route::get('0xdash/scache', function (Request $request) { - $validUser = 'admin'; - $validPass = 'admin@9608'; // 🔐 Change this to something secure - - if (! isset($_SERVER['PHP_AUTH_USER']) || - Request::server('PHP_AUTH_USER') !== $validUser || - Request::server('PHP_AUTH_PW') !== $validPass) { - - header('WWW-Authenticate: Basic realm="Restricted Area"'); - header('HTTP/1.0 401 Unauthorized'); - echo 'Unauthorized'; - exit; - } - Artisan::call('cache:clear'); - $output = Artisan::output(); - - return response()->json([ - 'message' => trim($output), - ]); - })->name('cacheClear'); - // Impersonation Routes Route::prefix('impersonation')->name('impersonation.')->group(function (): void { Route::post('/stop', [ImpersonationController::class, 'stop'])->name('stop'); diff --git a/tests/Concerns/LoadsApplicationData.php b/tests/Concerns/LoadsApplicationData.php index f6a8ad1..c7aaa4d 100644 --- a/tests/Concerns/LoadsApplicationData.php +++ b/tests/Concerns/LoadsApplicationData.php @@ -2,11 +2,11 @@ namespace Tests\Concerns; -use Exception; -use Illuminate\Support\Collection; use App\Models\Blog; use App\Models\Menu; use App\Models\Plan; +use Exception; +use Illuminate\Support\Collection; trait LoadsApplicationData { @@ -64,7 +64,7 @@ trait LoadsApplicationData try { $menus = cache()->remember('app_menus', now()->addHours(6), Menu::all(...)); - $blogs = cache()->remember('app_blogs', now()->addHours(6), fn() => Blog::query()->where('is_published', 1)->get()); + $blogs = cache()->remember('app_blogs', now()->addHours(6), fn () => Blog::query()->where('is_published', 1)->get()); $plans = cache()->remember('app_plans', now()->addHours(6), Plan::all(...)); } catch (Exception) { diff --git a/tests/Feature/Filament/ResourcesTest.php b/tests/Feature/Filament/ResourcesTest.php index 1ceafd9..d652533 100644 --- a/tests/Feature/Filament/ResourcesTest.php +++ b/tests/Feature/Filament/ResourcesTest.php @@ -2,30 +2,30 @@ namespace Tests\Feature\Filament; -use App\Filament\Resources\TicketResource\Pages\ListTickets; -use App\Filament\Resources\TicketResource\Pages\EditTicket; -use App\Filament\Resources\TicketResource\RelationManagers\ResponsesRelationManager; -use App\Filament\Resources\TicketResource; -use App\Filament\Resources\PlanResource\Pages\ListPlans; -use App\Filament\Resources\PlanResource\Pages\EditPlan; -use App\Filament\Resources\BlogResource\Pages\ListBlogs; +use App\Filament\Resources\BlogResource; +use App\Filament\Resources\BlogResource\Pages\CreateBlog; use App\Filament\Resources\BlogResource\Pages\EditBlog; -use App\Filament\Resources\CategoryResource\Pages\ListCategories; +use App\Filament\Resources\BlogResource\Pages\ListBlogs; use App\Filament\Resources\CategoryResource\Pages\CreateCategory; use App\Filament\Resources\CategoryResource\Pages\EditCategory; -use App\Filament\Resources\PageResource\Pages\ListPages; +use App\Filament\Resources\CategoryResource\Pages\ListCategories; +use App\Filament\Resources\MenuResource\Pages\CreateMenu; +use App\Filament\Resources\MenuResource\Pages\ListMenus; use App\Filament\Resources\PageResource\Pages\CreatePage; use App\Filament\Resources\PageResource\Pages\EditPage; -use App\Filament\Resources\MenuResource\Pages\ListMenus; -use App\Filament\Resources\MenuResource\Pages\CreateMenu; -use App\Filament\Resources\UserResource\Pages\ListUsers; -use App\Filament\Resources\UserResource; +use App\Filament\Resources\PageResource\Pages\ListPages; use App\Filament\Resources\PlanResource; -use App\Filament\Resources\BlogResource; -use App\Filament\Resources\UserResource\Pages\CreateUser; -use App\Filament\Resources\BlogResource\Pages\CreateBlog; use App\Filament\Resources\PlanResource\Pages\CreatePlan; +use App\Filament\Resources\PlanResource\Pages\EditPlan; +use App\Filament\Resources\PlanResource\Pages\ListPlans; +use App\Filament\Resources\TicketResource; use App\Filament\Resources\TicketResource\Pages\CreateTicket; +use App\Filament\Resources\TicketResource\Pages\EditTicket; +use App\Filament\Resources\TicketResource\Pages\ListTickets; +use App\Filament\Resources\TicketResource\RelationManagers\ResponsesRelationManager; +use App\Filament\Resources\UserResource; +use App\Filament\Resources\UserResource\Pages\CreateUser; +use App\Filament\Resources\UserResource\Pages\ListUsers; use App\Models\Blog; use App\Models\Category; use App\Models\Menu; @@ -40,6 +40,7 @@ use Tests\TestCase; class ResourcesTest extends TestCase { public $adminUser; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Feature/Filament/UserResourceTest.php b/tests/Feature/Filament/UserResourceTest.php index ce8e0df..20418a2 100644 --- a/tests/Feature/Filament/UserResourceTest.php +++ b/tests/Feature/Filament/UserResourceTest.php @@ -2,12 +2,12 @@ namespace Tests\Feature\Filament; -use App\Filament\Resources\UserResource\RelationManagers\LogsRelationManager; -use App\Filament\Resources\UserResource\RelationManagers\UsageLogsRelationManager; use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource\Pages\CreateUser; use App\Filament\Resources\UserResource\Pages\EditUser; use App\Filament\Resources\UserResource\Pages\ListUsers; +use App\Filament\Resources\UserResource\RelationManagers\LogsRelationManager; +use App\Filament\Resources\UserResource\RelationManagers\UsageLogsRelationManager; use App\Models\Log; use App\Models\User; use Livewire\Livewire; @@ -16,6 +16,7 @@ use Tests\TestCase; class UserResourceTest extends TestCase { public $adminUser; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Feature/Livewire/Auth/LoginTest.php b/tests/Feature/Livewire/Auth/LoginTest.php index 1102e32..a05d70a 100644 --- a/tests/Feature/Livewire/Auth/LoginTest.php +++ b/tests/Feature/Livewire/Auth/LoginTest.php @@ -13,6 +13,7 @@ use Tests\TestCase; class LoginTest extends TestCase { public $user; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php index 3266eaf..fe40715 100644 --- a/tests/Feature/Livewire/DashboardTest.php +++ b/tests/Feature/Livewire/DashboardTest.php @@ -12,6 +12,7 @@ use Tests\TestCase; class DashboardTest extends TestCase { public $user; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Feature/Livewire/FrontendTest.php b/tests/Feature/Livewire/FrontendTest.php index ee1fb92..4da945d 100644 --- a/tests/Feature/Livewire/FrontendTest.php +++ b/tests/Feature/Livewire/FrontendTest.php @@ -2,13 +2,13 @@ namespace Tests\Feature\Livewire; -use App\Livewire\Home; use App\Livewire\Frontend\Mailbox; -use Exception; -use App\Models\Blog; +use App\Livewire\Home; use App\Livewire\ListBlog; +use App\Models\Blog; use App\Models\Page; use App\Models\ZEmail; +use Exception; use Illuminate\Support\Facades\Cookie; use Livewire\Livewire; use Tests\TestCase; diff --git a/tests/Pest.php b/tests/Pest.php index fea8d58..1e809d4 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,7 +1,7 @@ extend(TestCase::class) | */ -expect()->extend('toBeOne', fn() => $this->toBe(1)); +expect()->extend('toBeOne', fn () => $this->toBe(1)); /* |-------------------------------------------------------------------------- diff --git a/tests/Unit/Models/ActivationKeyTest.php b/tests/Unit/Models/ActivationKeyTest.php index d6e5ee4..97ff756 100644 --- a/tests/Unit/Models/ActivationKeyTest.php +++ b/tests/Unit/Models/ActivationKeyTest.php @@ -9,6 +9,7 @@ use Tests\TestCase; class ActivationKeyTest extends TestCase { public $user; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Models/BlogTest.php b/tests/Unit/Models/BlogTest.php index cee8fa6..586d4e0 100644 --- a/tests/Unit/Models/BlogTest.php +++ b/tests/Unit/Models/BlogTest.php @@ -2,17 +2,19 @@ namespace Tests\Unit\Models; -use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Model; use App\Models\Blog; use App\Models\Category; use App\Models\User; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use Tests\TestCase; class BlogTest extends TestCase { private User|Collection $user; + public $category; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Models/CategoryTest.php b/tests/Unit/Models/CategoryTest.php index e20ca04..6e5cf1d 100644 --- a/tests/Unit/Models/CategoryTest.php +++ b/tests/Unit/Models/CategoryTest.php @@ -2,9 +2,9 @@ namespace Tests\Unit\Models; -use Illuminate\Database\Eloquent\Model; use App\Models\Blog; use App\Models\Category; +use Illuminate\Database\Eloquent\Model; use Tests\TestCase; class CategoryTest extends TestCase diff --git a/tests/Unit/Models/EmailTest.php b/tests/Unit/Models/EmailTest.php index dd6f413..8c6b385 100644 --- a/tests/Unit/Models/EmailTest.php +++ b/tests/Unit/Models/EmailTest.php @@ -2,10 +2,10 @@ namespace Tests\Unit\Models; -use Illuminate\Support\Facades\Date; use App\Models\Email; use Carbon\Carbon; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Date; use Tests\TestCase; class EmailTest extends TestCase diff --git a/tests/Unit/Models/LogTest.php b/tests/Unit/Models/LogTest.php index c055cd8..8f00e59 100644 --- a/tests/Unit/Models/LogTest.php +++ b/tests/Unit/Models/LogTest.php @@ -9,6 +9,7 @@ use Tests\TestCase; class LogTest extends TestCase { public $user; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Models/PlanTest.php b/tests/Unit/Models/PlanTest.php index c39981e..73f3ba0 100644 --- a/tests/Unit/Models/PlanTest.php +++ b/tests/Unit/Models/PlanTest.php @@ -2,13 +2,14 @@ namespace Tests\Unit\Models; -use Illuminate\Database\Eloquent\Model; use App\Models\Plan; +use Illuminate\Database\Eloquent\Model; use Tests\TestCase; class PlanTest extends TestCase { public $planData; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Models/PremiumEmailTest.php b/tests/Unit/Models/PremiumEmailTest.php index c4c8121..7fc69d7 100644 --- a/tests/Unit/Models/PremiumEmailTest.php +++ b/tests/Unit/Models/PremiumEmailTest.php @@ -10,6 +10,7 @@ use Tests\TestCase; class PremiumEmailTest extends TestCase { public $user; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Models/TicketResponseTest.php b/tests/Unit/Models/TicketResponseTest.php index 367b493..ef6b041 100644 --- a/tests/Unit/Models/TicketResponseTest.php +++ b/tests/Unit/Models/TicketResponseTest.php @@ -2,17 +2,19 @@ namespace Tests\Unit\Models; -use Illuminate\Database\Eloquent\Model; use App\Models\Ticket; use App\Models\TicketResponse; use App\Models\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Model; use Tests\TestCase; class TicketResponseTest extends TestCase { public $user; + public $ticket; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Models/TicketTest.php b/tests/Unit/Models/TicketTest.php index 4d91b94..2d9450f 100644 --- a/tests/Unit/Models/TicketTest.php +++ b/tests/Unit/Models/TicketTest.php @@ -2,17 +2,19 @@ namespace Tests\Unit\Models; -use Illuminate\Database\Eloquent\Model; use App\Models\Ticket; use App\Models\TicketResponse; use App\Models\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Model; use Tests\TestCase; class TicketTest extends TestCase { public $user; + public $ticketData; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Models/UsageLogTest.php b/tests/Unit/Models/UsageLogTest.php index d711a4d..172eab1 100644 --- a/tests/Unit/Models/UsageLogTest.php +++ b/tests/Unit/Models/UsageLogTest.php @@ -9,6 +9,7 @@ use Tests\TestCase; class UsageLogTest extends TestCase { public $user; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 37d3189..1089c6c 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -2,25 +2,26 @@ namespace Tests\Unit\Models; -use Carbon\Carbon; -use Illuminate\Support\Facades\Hash; -use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Notifications\Notifiable; -use Laravel\Cashier\Billable; -use Laravel\Sanctum\HasApiTokens; -use Filament\Models\Contracts\FilamentUser; -use Illuminate\Contracts\Auth\MustVerifyEmail; -use Laravel\Sanctum\NewAccessToken; use App\Models\Log; use App\Models\Ticket; use App\Models\UsageLog; use App\Models\User; +use Carbon\Carbon; +use Filament\Models\Contracts\FilamentUser; use Filament\Panel; +use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Hash; +use Laravel\Cashier\Billable; +use Laravel\Sanctum\HasApiTokens; +use Laravel\Sanctum\NewAccessToken; use Tests\TestCase; class UserTest extends TestCase { public $user; + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/NotifyMeTest.php b/tests/Unit/NotifyMeTest.php index 2c5d414..8217189 100644 --- a/tests/Unit/NotifyMeTest.php +++ b/tests/Unit/NotifyMeTest.php @@ -2,9 +2,9 @@ namespace Tests\Unit; -use Exception; use App\Http\Controllers\WebhookController; use App\NotifyMe; +use Exception; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -19,6 +19,7 @@ class TestNotifier class NotifyMeTest extends TestCase { public $notifier; + protected function setUp(): void { parent::setUp(); @@ -42,7 +43,7 @@ class NotifyMeTest extends TestCase $result = $this->notifier->sendTelegramNotification('Test message'); $this->assertTrue($result); - Http::assertSent(fn(array $request): bool => $request->url() === 'https://api.telegram.org/bottest_bot_token/sendMessage' && + Http::assertSent(fn (array $request): bool => $request->url() === 'https://api.telegram.org/bottest_bot_token/sendMessage' && $request['chat_id'] === 'test_chat_id' && $request['text'] === 'Test message' && $request['parse_mode'] === 'HTML'); @@ -133,7 +134,7 @@ class NotifyMeTest extends TestCase $htmlMessage = 'Bold text and italic text'; $this->notifier->sendTelegramNotification($htmlMessage); - Http::assertSent(fn(array $request): bool => $request['parse_mode'] === 'HTML' && + Http::assertSent(fn (array $request): bool => $request['parse_mode'] === 'HTML' && $request['text'] === $htmlMessage); }