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
This commit is contained in:
idevakk
2026-02-28 23:17:39 +05:30
parent bf5b797cd8
commit c312ec3325
78 changed files with 750 additions and 360 deletions

17
.docker/entrypoint.sh Normal file
View File

@@ -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

32
.docker/nginx.conf Normal file
View File

@@ -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;
}
}

51
.docker/supervisord.conf Normal file
View File

@@ -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

76
Project.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Models\Email;
use Illuminate\Console\Command;
class CleanAttachmentsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'attachments:clean';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clean old email attachments from disk';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Starting to clean attachments...');
Email::deleteBulkAttachments();
$this->info('Finished cleaning attachments.');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Models\Email;
use Illuminate\Console\Command;
class CleanMailboxCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'mailbox:clean';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clean old messages from the IMAP mailbox';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Starting mailbox cleanup...');
$result = Email::cleanMailbox();
$this->info($result);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Models\Email;
use Illuminate\Console\Command;
class FetchEmailsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'emails:fetch';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fetch, process, and store emails from the IMAP server';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Starting to fetch emails from IMAP server...');
Email::fetchProcessStoreEmail();
$this->info('Finished fetching emails.');
}
}

View File

@@ -13,7 +13,6 @@ use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon; use Filament\Support\Icons\Heroicon;
use Inerba\DbConfig\AbstractPageSettings; use Inerba\DbConfig\AbstractPageSettings;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
class ImapSettings extends AbstractPageSettings class ImapSettings extends AbstractPageSettings
{ {
@@ -145,7 +144,7 @@ class ImapSettings extends AbstractPageSettings
$fields = ['host', 'port', 'username', 'password', 'encryption', 'validate_cert', 'protocol']; $fields = ['host', 'port', 'username', 'password', 'encryption', 'validate_cert', 'protocol'];
foreach ($fields as $field) { foreach ($fields as $field) {
$key = $sectionName . '.' . $field; $key = $sectionName.'.'.$field;
// Try different data structure approaches // Try different data structure approaches
$value = null; $value = null;
@@ -186,8 +185,8 @@ class ImapSettings extends AbstractPageSettings
return [ return [
'section' => ucfirst($sectionName), 'section' => ucfirst($sectionName),
'success' => false, 'success' => false,
'message' => "Missing required fields: " . $missingFields->join(', '), 'message' => 'Missing required fields: '.$missingFields->join(', '),
'details' => null 'details' => null,
]; ];
} }
@@ -198,7 +197,7 @@ class ImapSettings extends AbstractPageSettings
'section' => ucfirst($sectionName), 'section' => ucfirst($sectionName),
'success' => false, 'success' => false,
'message' => 'IMAP extension is not loaded in your web server. Please check your Herd PHP configuration or restart your server.', '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'], 'password' => $config['password'],
'encryption' => $config['encryption'] ?? 'none', 'encryption' => $config['encryption'] ?? 'none',
'validate_cert' => $config['validate_cert'] ?? false, 'validate_cert' => $config['validate_cert'] ?? false,
'protocol' => $config['protocol'] ?? 'imap' 'protocol' => $config['protocol'] ?? 'imap',
]; ];
// Test connection using the existing ZEmail::connectMailBox method // Test connection using the existing ZEmail::connectMailBox method
@@ -224,8 +223,8 @@ class ImapSettings extends AbstractPageSettings
'host' => $config['host'], 'host' => $config['host'],
'port' => $config['port'], 'port' => $config['port'],
'encryption' => $config['encryption'] ?? 'none', 'encryption' => $config['encryption'] ?? 'none',
'protocol' => $config['protocol'] ?? 'imap' 'protocol' => $config['protocol'] ?? 'imap',
] ],
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -244,13 +243,12 @@ class ImapSettings extends AbstractPageSettings
'host' => $config['host'] ?? null, 'host' => $config['host'] ?? null,
'port' => $config['port'] ?? null, 'port' => $config['port'] ?? null,
'encryption' => $config['encryption'] ?? 'none', 'encryption' => $config['encryption'] ?? 'none',
'protocol' => $config['protocol'] ?? 'imap' 'protocol' => $config['protocol'] ?? 'imap',
] ],
]; ];
} }
} }
/** /**
* Send appropriate notification based on test results. * Send appropriate notification based on test results.
*/ */
@@ -294,6 +292,7 @@ class ImapSettings extends AbstractPageSettings
$details[] = "{$result['section']}: {$result['details']['messages']} messages"; $details[] = "{$result['section']}: {$result['details']['messages']} messages";
} }
} }
return implode(' | ', $details); return implode(' | ', $details);
} }
@@ -306,6 +305,7 @@ class ImapSettings extends AbstractPageSettings
foreach ($results as $result) { foreach ($results as $result) {
$details[] = "{$result['section']}: {$result['message']}"; $details[] = "{$result['section']}: {$result['message']}";
} }
return implode(' | ', $details); return implode(' | ', $details);
} }
@@ -319,6 +319,7 @@ class ImapSettings extends AbstractPageSettings
$status = $result['success'] ? '✅' : '❌'; $status = $result['success'] ? '✅' : '❌';
$details[] = "{$status} {$result['section']}"; $details[] = "{$status} {$result['section']}";
} }
return implode(' | ', $details); return implode(' | ', $details);
} }
} }

View File

@@ -27,7 +27,6 @@ use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
class ImpersonationLogViewer extends Page implements HasForms, HasTable class ImpersonationLogViewer extends Page implements HasForms, HasTable
@@ -132,10 +131,10 @@ class ImpersonationLogViewer extends Page implements HasForms, HasTable
TextColumn::make('duration_in_minutes') TextColumn::make('duration_in_minutes')
->label('Duration') ->label('Duration')
->formatStateUsing(function ($record) { ->formatStateUsing(function ($record) {
return match(true) { return match (true) {
!$record->duration_in_minutes => 'Active', ! $record->duration_in_minutes => 'Active',
$record->duration_in_minutes < 60 => "{$record->duration_in_minutes}m", $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() ->sortable()
@@ -324,7 +323,7 @@ class ImpersonationLogViewer extends Page implements HasForms, HasTable
->latest('start_time') ->latest('start_time')
->get(); ->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 // Create a temporary file
$handle = fopen('php://temp', 'r+'); $handle = fopen('php://temp', 'r+');
@@ -376,7 +375,7 @@ class ImpersonationLogViewer extends Page implements HasForms, HasTable
$filename, $filename,
[ [
'Content-Type' => 'text/csv', 'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Disposition' => 'attachment; filename="'.$filename.'"',
] ]
); );
} }

View File

@@ -2,13 +2,12 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use BackedEnum;
use UnitEnum;
use App\Filament\Resources\BlogResource\Pages\CreateBlog; use App\Filament\Resources\BlogResource\Pages\CreateBlog;
use App\Filament\Resources\BlogResource\Pages\EditBlog; use App\Filament\Resources\BlogResource\Pages\EditBlog;
use App\Filament\Resources\BlogResource\Pages\ListBlogs; use App\Filament\Resources\BlogResource\Pages\ListBlogs;
use App\Models\Blog; use App\Models\Blog;
use App\Models\Category; use App\Models\Category;
use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
@@ -30,6 +29,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use UnitEnum;
class BlogResource extends Resource class BlogResource extends Resource
{ {

View File

@@ -2,12 +2,11 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use BackedEnum;
use UnitEnum;
use App\Filament\Resources\CategoryResource\Pages\CreateCategory; use App\Filament\Resources\CategoryResource\Pages\CreateCategory;
use App\Filament\Resources\CategoryResource\Pages\EditCategory; use App\Filament\Resources\CategoryResource\Pages\EditCategory;
use App\Filament\Resources\CategoryResource\Pages\ListCategories; use App\Filament\Resources\CategoryResource\Pages\ListCategories;
use App\Models\Category; use App\Models\Category;
use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
@@ -23,6 +22,7 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use UnitEnum;
class CategoryResource extends Resource class CategoryResource extends Resource
{ {
@@ -60,7 +60,7 @@ class CategoryResource extends Resource
TextColumn::make('slug'), TextColumn::make('slug'),
TextColumn::make('blogs_count') TextColumn::make('blogs_count')
->label('Blogs') ->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(), IconColumn::make('is_active')->label('Active')->boolean(),
]) ])
->filters([ ->filters([

View File

@@ -2,12 +2,11 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use BackedEnum;
use UnitEnum;
use App\Filament\Resources\MenuResource\Pages\CreateMenu; use App\Filament\Resources\MenuResource\Pages\CreateMenu;
use App\Filament\Resources\MenuResource\Pages\EditMenu; use App\Filament\Resources\MenuResource\Pages\EditMenu;
use App\Filament\Resources\MenuResource\Pages\ListMenus; use App\Filament\Resources\MenuResource\Pages\ListMenus;
use App\Models\Menu; use App\Models\Menu;
use BackedEnum;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
@@ -21,6 +20,7 @@ use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use UnitEnum;
class MenuResource extends Resource class MenuResource extends Resource
{ {

View File

@@ -2,12 +2,11 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use BackedEnum;
use UnitEnum;
use App\Filament\Resources\PageResource\Pages\CreatePage; use App\Filament\Resources\PageResource\Pages\CreatePage;
use App\Filament\Resources\PageResource\Pages\EditPage; use App\Filament\Resources\PageResource\Pages\EditPage;
use App\Filament\Resources\PageResource\Pages\ListPages; use App\Filament\Resources\PageResource\Pages\ListPages;
use App\Models\Page; use App\Models\Page;
use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
@@ -29,6 +28,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use UnitEnum;
class PageResource extends Resource class PageResource extends Resource
{ {

View File

@@ -2,12 +2,12 @@
namespace App\Filament\Resources\PaymentProviders\Tables; namespace App\Filament\Resources\PaymentProviders\Tables;
use Filament\Actions\Action;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\Action;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;

View File

@@ -301,6 +301,7 @@ class PlanResource extends Resource
// Halt the bulk deletion process // Halt the bulk deletion process
$action->halt(); $action->halt();
return; return;
} }
} }

View File

@@ -2,8 +2,6 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use BackedEnum;
use UnitEnum;
use App\Filament\Resources\TicketResource\Pages\CreateTicket; use App\Filament\Resources\TicketResource\Pages\CreateTicket;
use App\Filament\Resources\TicketResource\Pages\EditTicket; use App\Filament\Resources\TicketResource\Pages\EditTicket;
use App\Filament\Resources\TicketResource\Pages\ListTickets; use App\Filament\Resources\TicketResource\Pages\ListTickets;
@@ -11,6 +9,7 @@ use App\Filament\Resources\TicketResource\RelationManagers\ResponsesRelationMana
use App\Mail\TicketResponseNotification; use App\Mail\TicketResponseNotification;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketResponse; use App\Models\TicketResponse;
use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
@@ -33,6 +32,7 @@ use Filament\Tables\Table;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use UnitEnum;
class TicketResource extends Resource class TicketResource extends Resource
{ {
@@ -120,7 +120,7 @@ class TicketResource extends Resource
DatePicker::make('created_from')->label('Created From'), DatePicker::make('created_from')->label('Created From'),
DatePicker::make('created_until')->label('Created Until'), 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_from'], fn ($query, $date) => $query->whereDate('created_at', '>=', $date))
->when($data['created_until'], 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') Action::make('view')
->label('View & Respond') ->label('View & Respond')
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->schema(fn(Ticket $ticket): array => [ ->schema(fn (Ticket $ticket): array => [
TextArea::make('response') TextArea::make('response')
->label('Your Response') ->label('Your Response')
->required() ->required()

View File

@@ -34,6 +34,7 @@ class TrialExtensionForm
if ($subscription->trial_ends_at) { if ($subscription->trial_ends_at) {
$label .= " ({$subscription->trial_ends_at->format('M j, Y')})"; $label .= " ({$subscription->trial_ends_at->format('M j, Y')})";
} }
return [$subscription->id => $label]; return [$subscription->id => $label];
}) })
->toArray(); ->toArray();
@@ -158,12 +159,14 @@ class TrialExtensionForm
if (! $subscriptionId || ! $extensionDays) { if (! $subscriptionId || ! $extensionDays) {
$set('new_trial_ends_at', null); $set('new_trial_ends_at', null);
return; return;
} }
$subscription = Subscription::find($subscriptionId); $subscription = Subscription::find($subscriptionId);
if (! $subscription) { if (! $subscription) {
$set('new_trial_ends_at', null); $set('new_trial_ends_at', null);
return; return;
} }

View File

@@ -96,7 +96,7 @@ class TrialExtensionsTable
->label('View Subscription') ->label('View Subscription')
->icon('heroicon-o-rectangle-stack') ->icon('heroicon-o-rectangle-stack')
->color('blue') ->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(), ->openUrlInNewTab(),
]) ])
->toolbarActions([ ->toolbarActions([

View File

@@ -7,7 +7,6 @@ use App\Filament\Resources\TrialExtensions\Pages\EditTrialExtension;
use App\Filament\Resources\TrialExtensions\Pages\ListTrialExtensions; use App\Filament\Resources\TrialExtensions\Pages\ListTrialExtensions;
use App\Filament\Resources\TrialExtensions\Schemas\TrialExtensionForm; use App\Filament\Resources\TrialExtensions\Schemas\TrialExtensionForm;
use App\Filament\Resources\TrialExtensions\Tables\TrialExtensionsTable; use App\Filament\Resources\TrialExtensions\Tables\TrialExtensionsTable;
use App\Models\Subscription;
use App\Models\TrialExtension; use App\Models\TrialExtension;
use BackedEnum; use BackedEnum;
use Filament\Resources\Resource; use Filament\Resources\Resource;

View File

@@ -2,8 +2,6 @@
namespace App\Filament\Widgets; namespace App\Filament\Widgets;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use App\Models\Log; use App\Models\Log;
use App\Models\Meta; use App\Models\Meta;
use App\Models\PremiumEmail; use App\Models\PremiumEmail;
@@ -11,6 +9,8 @@ use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
class StatsOverview extends BaseWidget class StatsOverview extends BaseWidget
{ {

View File

@@ -14,7 +14,7 @@ class ArrayHelper
$keys = explode('.', $key); $keys = explode('.', $key);
foreach ($keys as $segment) { foreach ($keys as $segment) {
if (!isset($array[$segment])) { if (! isset($array[$segment])) {
return null; return null;
} }
$array = $array[$segment]; $array = $array[$segment];
@@ -32,7 +32,7 @@ class ArrayHelper
); );
} catch (\JsonException $e) { } catch (\JsonException $e) {
// Optional: Log the error // 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 // Fallback: return empty object instead of crashing
return '{}'; return '{}';

View File

@@ -2,11 +2,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Session;
use Illuminate\Routing\Redirector;
use Illuminate\Http\RedirectResponse;
use App\Models\Premium; use App\Models\Premium;
use App\Models\ZEmail; use App\Models\ZEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
class AppController extends Controller class AppController extends Controller
{ {
@@ -56,6 +55,7 @@ class AppController extends Controller
return to_route('mailbox'); return to_route('mailbox');
} }
return to_route('home'); return to_route('home');
} }
@@ -86,6 +86,7 @@ class AppController extends Controller
return to_route('dashboard.premium'); return to_route('dashboard.premium');
} }
return to_route('dashboard'); return to_route('dashboard');
} }

View File

@@ -18,7 +18,7 @@ class PaymentCancelController extends Controller
Log::info('PaymentCancelController: Cancellation page accessed', [ Log::info('PaymentCancelController: Cancellation page accessed', [
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'session_token' => $sessionToken ? substr($sessionToken, 0, 20) . '...' : 'none', 'session_token' => $sessionToken ? substr($sessionToken, 0, 20).'...' : 'none',
'ip_address' => $request->ip(), 'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(), 'user_agent' => $request->userAgent(),
]); ]);

View File

@@ -2,9 +2,9 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Illuminate\Support\Facades\Auth;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class CheckUserBanned class CheckUserBanned

View File

@@ -2,8 +2,8 @@
namespace App\Livewire\Actions; namespace App\Livewire\Actions;
use Illuminate\Routing\Redirector;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;

View File

@@ -2,10 +2,10 @@
namespace App\Livewire; namespace App\Livewire;
use Illuminate\Support\Facades\Session; use App\Models\ZEmail;
use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use App\Models\ZEmail; use Illuminate\Support\Facades\Session;
use Livewire\Component; use Livewire\Component;
class AddOn extends Component class AddOn extends Component
@@ -31,6 +31,7 @@ class AddOn extends Component
if (count($messages['data']) > 0) { if (count($messages['data']) > 0) {
return to_route('mailbox'); return to_route('mailbox');
} }
return null; return null;
} }
@@ -48,7 +49,7 @@ class AddOn extends Component
$this->faqSchema = [ $this->faqSchema = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'FAQPage', '@type' => 'FAQPage',
'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [
'@type' => 'Question', '@type' => 'Question',
'name' => $faq['title'], 'name' => $faq['title'],
'acceptedAnswer' => [ 'acceptedAnswer' => [
@@ -66,7 +67,7 @@ class AddOn extends Component
$this->faqSchema = [ $this->faqSchema = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'FAQPage', '@type' => 'FAQPage',
'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [
'@type' => 'Question', '@type' => 'Question',
'name' => $faq['title'], 'name' => $faq['title'],
'acceptedAnswer' => [ 'acceptedAnswer' => [
@@ -85,7 +86,7 @@ class AddOn extends Component
$this->faqSchema = [ $this->faqSchema = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'FAQPage', '@type' => 'FAQPage',
'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [
'@type' => 'Question', '@type' => 'Question',
'name' => $faq['title'], 'name' => $faq['title'],
'acceptedAnswer' => [ 'acceptedAnswer' => [
@@ -103,7 +104,7 @@ class AddOn extends Component
$this->faqSchema = [ $this->faqSchema = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'FAQPage', '@type' => 'FAQPage',
'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [
'@type' => 'Question', '@type' => 'Question',
'name' => $faq['title'], 'name' => $faq['title'],
'acceptedAnswer' => [ 'acceptedAnswer' => [
@@ -121,7 +122,7 @@ class AddOn extends Component
$this->faqSchema = [ $this->faqSchema = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'FAQPage', '@type' => 'FAQPage',
'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [
'@type' => 'Question', '@type' => 'Question',
'name' => $faq['title'], 'name' => $faq['title'],
'acceptedAnswer' => [ 'acceptedAnswer' => [
@@ -139,7 +140,7 @@ class AddOn extends Component
$this->faqSchema = [ $this->faqSchema = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'FAQPage', '@type' => 'FAQPage',
'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [
'@type' => 'Question', '@type' => 'Question',
'name' => $faq['title'], 'name' => $faq['title'],
'acceptedAnswer' => [ 'acceptedAnswer' => [
@@ -157,7 +158,7 @@ class AddOn extends Component
$this->faqSchema = [ $this->faqSchema = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'FAQPage', '@type' => 'FAQPage',
'mainEntity' => collect($this->faqs)->map(fn(array $faq): array => [ 'mainEntity' => collect($this->faqs)->map(fn (array $faq): array => [
'@type' => 'Question', '@type' => 'Question',
'name' => $faq['title'], 'name' => $faq['title'],
'acceptedAnswer' => [ 'acceptedAnswer' => [

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Dashboard; namespace App\Livewire\Dashboard;
use App\Models\Subscription;
use App\Models\UsageLog; use App\Models\UsageLog;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -10,7 +11,6 @@ use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Livewire\Component; use Livewire\Component;
use Stripe\StripeClient; use Stripe\StripeClient;
use App\Models\Subscription;
class Dashboard extends Component class Dashboard extends Component
{ {

View File

@@ -2,11 +2,11 @@
namespace App\Livewire\Dashboard; namespace App\Livewire\Dashboard;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Request;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketResponse; use App\Models\TicketResponse;
use Exception; use Exception;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
class Support extends Component class Support extends Component
@@ -129,9 +129,9 @@ class Support extends Component
public function updateTicketCounts(): void 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() protected function getClientIp()

View File

@@ -2,9 +2,9 @@
namespace App\Livewire\Frontend; namespace App\Livewire\Frontend;
use Illuminate\Routing\Redirector;
use Illuminate\Http\RedirectResponse;
use App\Models\ZEmail; use App\Models\ZEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
use Livewire\Component; use Livewire\Component;
class Email extends Component class Email extends Component

View File

@@ -40,6 +40,7 @@ class Mailbox extends Component
if (! ZEmail::getEmail()) { if (! ZEmail::getEmail()) {
return to_route('home'); return to_route('home');
} }
return null; return null;
} }

View File

@@ -2,9 +2,9 @@
namespace App\Livewire; namespace App\Livewire;
use App\Models\ZEmail;
use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use App\Models\ZEmail;
use Livewire\Component; use Livewire\Component;
class Home extends Component class Home extends Component
@@ -18,6 +18,7 @@ class Home extends Component
if (count($messages['data']) > 0) { if (count($messages['data']) > 0) {
return to_route('mailbox'); return to_route('mailbox');
} }
return null; return null;
} }

View File

@@ -18,9 +18,7 @@ class TicketResponseNotification extends Mailable
/** /**
* Create a new message instance. * 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. * Get the message envelope.

View File

@@ -2,18 +2,17 @@
namespace App\Models; namespace App\Models;
use DateTimeImmutable;
use Illuminate\Support\Facades\Date;
use App\ColorPicker; use App\ColorPicker;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use DateTime; use DateTime;
use DateTimeImmutable;
use Ddeboer\Imap\ConnectionInterface; use Ddeboer\Imap\ConnectionInterface;
use Ddeboer\Imap\Search\Date\Since; use Ddeboer\Imap\Search\Date\Since;
use Ddeboer\Imap\Server; use Ddeboer\Imap\Server;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -78,7 +77,7 @@ class Email extends Model
$sender = $message->getFrom(); $sender = $message->getFrom();
$date = $message->getDate(); $date = $message->getDate();
if (!$date instanceof DateTimeImmutable) { if (! $date instanceof DateTimeImmutable) {
$date = new DateTime; $date = new DateTime;
if ($message->getHeaders()->get('udate')) { if ($message->getHeaders()->get('udate')) {
$date->setTimestamp($message->getHeaders()->get('udate')); $date->setTimestamp($message->getHeaders()->get('udate'));
@@ -100,11 +99,11 @@ class Email extends Model
$obj = []; $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(); $messageTime = $message->getDate();
$utcTime = CarbonImmutable::instance($messageTime)->setTimezone('UTC')->toDateTimeString(); $utcTime = CarbonImmutable::instance($messageTime)->setTimezone('UTC')->toDateTimeString();
@@ -127,28 +126,27 @@ class Email extends Model
if ($message->hasAttachments()) { if ($message->hasAttachments()) {
$attachments = $message->getAttachments(); $attachments = $message->getAttachments();
$directory = './tmp/attachments/'.$obj['id'].'/'; $directoryPath = 'attachments/'.$obj['id'];
if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}
foreach ($attachments as $attachment) { foreach ($attachments as $attachment) {
$filenameArray = explode('.', (string) $attachment->getFilename()); $filenameArray = explode('.', (string) $attachment->getFilename());
$extension = $filenameArray[count($filenameArray) - 1]; $extension = $filenameArray[count($filenameArray) - 1];
if (in_array($extension, $allowed)) { 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 { try {
file_put_contents( \Illuminate\Support\Facades\Storage::disk('public')->put(
$directory.$attachment->getFilename(), $filePath,
$attachment->getDecodedContent() $attachment->getDecodedContent()
); );
} catch (Exception $e) { } catch (Exception $e) {
Log::error($e->getMessage()); Log::error($e->getMessage());
} }
} }
if ($attachment->getFilename() !== 'undefined') { 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(); $structure = $attachment->getStructure();
if (isset($structure->id) && str_contains($obj['content'], trim($structure->id, '<>'))) { if (isset($structure->id) && str_contains($obj['content'], trim($structure->id, '<>'))) {
$obj['content'] = str_replace('cid:'.trim($structure->id, '<>'), $url, $obj['content']); $obj['content'] = str_replace('cid:'.trim($structure->id, '<>'), $url, $obj['content']);
@@ -159,9 +157,7 @@ class Email extends Model
]; ];
} }
} }
} }
} }
$response['data'][] = $obj; $response['data'][] = $obj;
@@ -354,11 +350,9 @@ class Email extends Model
public static function deleteBulkAttachments(): void public static function deleteBulkAttachments(): void
{ {
$dir = public_path('/tmp/attachments');
try { try {
if (File::exists($dir)) { if (\Illuminate\Support\Facades\Storage::disk('public')->exists('attachments')) {
File::cleanDirectory($dir); \Illuminate\Support\Facades\Storage::disk('public')->deleteDirectory('attachments');
} }
} catch (Exception $e) { } catch (Exception $e) {
Log::error($e->getMessage()); Log::error($e->getMessage());
@@ -431,6 +425,7 @@ class Email extends Model
$currentTime = Date::now('UTC'); $currentTime = Date::now('UTC');
$lastRecordTime = Date::parse($latestRecord->timestamp); $lastRecordTime = Date::parse($latestRecord->timestamp);
return $lastRecordTime->diffInMinutes($currentTime) < 5; return $lastRecordTime->diffInMinutes($currentTime) < 5;
} }

View File

@@ -2,9 +2,9 @@
namespace App\Models; namespace App\Models;
use Illuminate\Support\Facades\Date;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Date;
class Log extends Model class Log extends Model
{ {

View File

@@ -2,10 +2,10 @@
namespace App\Models; namespace App\Models;
use DateTimeImmutable;
use App\ColorPicker; use App\ColorPicker;
use Carbon\Carbon; use Carbon\Carbon;
use DateTime; use DateTime;
use DateTimeImmutable;
use Ddeboer\Imap\Search\Email\Cc; use Ddeboer\Imap\Search\Email\Cc;
use Ddeboer\Imap\Search\Email\To; use Ddeboer\Imap\Search\Email\To;
use Ddeboer\Imap\SearchExpression; use Ddeboer\Imap\SearchExpression;
@@ -41,7 +41,7 @@ class Message extends Model
$message->attachments = $request->get('attachment-info'); $message->attachments = $request->get('attachment-info');
$message->save(); $message->save();
$directory = './attachments/'.$message->id; $directory = './attachments/'.$message->id;
if (!is_dir($directory)) { if (! is_dir($directory)) {
mkdir($directory, 0777, true); mkdir($directory, 0777, true);
} }
$attachment_ids = json_decode((string) $request->get('attachment-info')); $attachment_ids = json_decode((string) $request->get('attachment-info'));
@@ -146,7 +146,7 @@ class Message extends Model
$blocked = false; $blocked = false;
$sender = $message->getFrom(); $sender = $message->getFrom();
$date = $message->getDate(); $date = $message->getDate();
if (!$date instanceof DateTimeImmutable) { if (! $date instanceof DateTimeImmutable) {
$date = new DateTime; $date = new DateTime;
if ($message->getHeaders()->get('udate')) { if ($message->getHeaders()->get('udate')) {
$date->setTimestamp($message->getHeaders()->get('udate')); $date->setTimestamp($message->getHeaders()->get('udate'));
@@ -193,7 +193,7 @@ class Message extends Model
if ($message->hasAttachments() && ! $blocked) { if ($message->hasAttachments() && ! $blocked) {
$attachments = $message->getAttachments(); $attachments = $message->getAttachments();
$directory = './tmp/attachments/'.$obj['id'].'/'; $directory = './tmp/attachments/'.$obj['id'].'/';
if (!is_dir($directory)) { if (! is_dir($directory)) {
mkdir($directory, 0777, true); mkdir($directory, 0777, true);
} }
foreach ($attachments as $attachment) { foreach ($attachments as $attachment) {

View File

@@ -2,11 +2,11 @@
namespace App\Models; namespace App\Models;
use Illuminate\Support\Facades\Date;
use App\ColorPicker; use App\ColorPicker;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
class PremiumEmail extends Model class PremiumEmail extends Model

View File

@@ -2,9 +2,9 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Ddeboer\Imap\ConnectionInterface; use Ddeboer\Imap\ConnectionInterface;
use Ddeboer\Imap\Server; use Ddeboer\Imap\Server;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cookie; use Illuminate\Support\Facades\Cookie;
@@ -14,6 +14,7 @@ class ZEmail extends Model
{ {
use HasFactory; use HasFactory;
use HasFactory; use HasFactory;
public static function connectMailBox($imap = null): ConnectionInterface public static function connectMailBox($imap = null): ConnectionInterface
{ {
if ($imap === null) { if ($imap === null) {
@@ -38,6 +39,7 @@ class ZEmail extends Model
if (Email::mailToDBStatus()) { if (Email::mailToDBStatus()) {
return Email::parseEmail($email, $deleted); return Email::parseEmail($email, $deleted);
} }
return Message::fetchMessages($email, $type, $deleted); return Message::fetchMessages($email, $type, $deleted);
} }
@@ -57,6 +59,7 @@ class ZEmail extends Model
if (Cookie::has('email')) { if (Cookie::has('email')) {
return Cookie::get('email'); return Cookie::get('email');
} }
return $generate ? ZEmail::generateRandomEmail() : null; return $generate ? ZEmail::generateRandomEmail() : null;
} }
@@ -65,6 +68,7 @@ class ZEmail extends Model
if (Cookie::has('emails')) { if (Cookie::has('emails')) {
return unserialize(Cookie::get('emails')); return unserialize(Cookie::get('emails'));
} }
return []; return [];
} }

View File

@@ -81,7 +81,7 @@ class AppServiceProvider extends ServiceProvider
$this->appConfig = [ $this->appConfig = [
'website_settings' => [], 'website_settings' => [],
'imap_settings' => [], 'imap_settings' => [],
'configuration_settings' => [] 'configuration_settings' => [],
]; ];
Log::error($e->getMessage()); Log::error($e->getMessage());
} }
@@ -114,40 +114,40 @@ class AppServiceProvider extends ServiceProvider
private function loadLegacySettings(): array private function loadLegacySettings(): array
{ {
return [ return [
"app_name" => $this->getConfig('website_settings.app_name'), 'app_name' => $this->getConfig('website_settings.app_name'),
"app_version" => $this->getConfig('website_settings.app_version'), 'app_version' => $this->getConfig('website_settings.app_version'),
"app_base_url" => $this->getConfig('website_settings.app_base_url'), 'app_base_url' => $this->getConfig('website_settings.app_base_url'),
"app_admin" => $this->getConfig('website_settings.app_admin'), 'app_admin' => $this->getConfig('website_settings.app_admin'),
"app_title" => $this->getConfig('website_settings.app_title'), 'app_title' => $this->getConfig('website_settings.app_title'),
"app_description" => $this->getConfig('website_settings.app_description'), 'app_description' => $this->getConfig('website_settings.app_description'),
"app_keywords" => $this->getConfig('website_settings.app_keywords'), 'app_keywords' => $this->getConfig('website_settings.app_keywords'),
"app_contact" => $this->getConfig('website_settings.app_contact'), 'app_contact' => $this->getConfig('website_settings.app_contact'),
"app_meta" => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.app_meta')), 'app_meta' => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.app_meta')),
"app_social" => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.app_social')), 'app_social' => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.app_social')),
"app_header" => $this->getConfig('website_settings.app_header'), 'app_header' => $this->getConfig('website_settings.app_header'),
"app_footer" => $this->getConfig('website_settings.app_footer'), 'app_footer' => $this->getConfig('website_settings.app_footer'),
"imap_settings" => ArrayHelper::jsonEncodeSafe([ 'imap_settings' => ArrayHelper::jsonEncodeSafe([
"host" => $this->getConfig('imap_settings.public.host'), 'host' => $this->getConfig('imap_settings.public.host'),
"port" => $this->getConfig('imap_settings.public.port'), 'port' => $this->getConfig('imap_settings.public.port'),
"username" => $this->getConfig('imap_settings.public.username'), 'username' => $this->getConfig('imap_settings.public.username'),
"password" => $this->getConfig('imap_settings.public.password'), 'password' => $this->getConfig('imap_settings.public.password'),
"encryption" => $this->getConfig('imap_settings.public.encryption'), 'encryption' => $this->getConfig('imap_settings.public.encryption'),
"validate_cert" => $this->getConfig('imap_settings.public.validate_cert'), 'validate_cert' => $this->getConfig('imap_settings.public.validate_cert'),
"default_account" => $this->getConfig('imap_settings.public.default_account'), 'default_account' => $this->getConfig('imap_settings.public.default_account'),
"protocol" => $this->getConfig('imap_settings.public.protocol'), 'protocol' => $this->getConfig('imap_settings.public.protocol'),
"cc_check" => $this->getConfig('imap_settings.public.cc_check'), 'cc_check' => $this->getConfig('imap_settings.public.cc_check'),
"premium_host" => $this->getConfig('imap_settings.premium.host'), 'premium_host' => $this->getConfig('imap_settings.premium.host'),
"premium_port" => $this->getConfig('imap_settings.premium.port'), 'premium_port' => $this->getConfig('imap_settings.premium.port'),
"premium_username" => $this->getConfig('imap_settings.premium.username'), 'premium_username' => $this->getConfig('imap_settings.premium.username'),
"premium_password" => $this->getConfig('imap_settings.premium.password'), 'premium_password' => $this->getConfig('imap_settings.premium.password'),
"premium_encryption" => $this->getConfig('imap_settings.premium.premium_encryption'), 'premium_encryption' => $this->getConfig('imap_settings.premium.premium_encryption'),
"premium_validate_cert" => $this->getConfig('imap_settings.premium.validate_cert'), 'premium_validate_cert' => $this->getConfig('imap_settings.premium.validate_cert'),
"premium_default_account" => $this->getConfig('imap_settings.premium.default_account'), 'premium_default_account' => $this->getConfig('imap_settings.premium.default_account'),
"premium_protocol" => $this->getConfig('imap_settings.premium.protocol'), 'premium_protocol' => $this->getConfig('imap_settings.premium.protocol'),
"premium_cc_check" => $this->getConfig('imap_settings.premium.cc_check'), 'premium_cc_check' => $this->getConfig('imap_settings.premium.cc_check'),
]), ]),
"configuration_settings" => ArrayHelper::jsonEncodeSafe($this->appConfig['configuration_settings']), 'configuration_settings' => ArrayHelper::jsonEncodeSafe($this->appConfig['configuration_settings']),
"ads_settings" => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.ads_settings')), 'ads_settings' => ArrayHelper::jsonEncodeSafe($this->getConfig('website_settings.ads_settings')),
]; ];
} }

View File

@@ -35,7 +35,7 @@ class OxapayProvider implements PaymentProviderContract
$config = array_merge($dbConfig, $config); $config = array_merge($dbConfig, $config);
$this->config = $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->merchantApiKey = $this->sandbox ? ($config['sandbox_merchant_api_key'] ?? '') : ($config['merchant_api_key'] ?? '');
$this->baseUrl = 'https://api.oxapay.com/v1'; $this->baseUrl = 'https://api.oxapay.com/v1';

View File

@@ -10,13 +10,13 @@ $app = require_once __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class); $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
try { try {
// Run the Artisan command 'ping' // Run the Artisan command
$exitCode = $kernel->call('cleanMail'); $exitCode = $kernel->call('mailbox:clean');
// Get the output of the command // Get the output of the command
$output = $kernel->output(); $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"; echo "Output:\n$output";
} catch (\Exception $e) { } catch (\Exception $e) {

View File

@@ -45,7 +45,7 @@ return [
'pattern' => [ 'pattern' => [
'prefix' => 'laravel-', 'prefix' => 'laravel-',
'date' => '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]', 'date' => '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]',
'extension' => '.log' 'extension' => '.log',
], ],
/* ----------------------------------------------------------------- /* -----------------------------------------------------------------

View File

@@ -1,8 +1,8 @@
<?php <?php
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
return [ return [

View File

@@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\TicketResponse;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketResponse;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;

View File

@@ -1,8 +1,8 @@
<?php <?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateActivityLogTable extends Migration class CreateActivityLogTable extends Migration
{ {

View File

@@ -1,8 +1,8 @@
<?php <?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddEventColumnToActivityLogTable extends Migration class AddEventColumnToActivityLogTable extends Migration
{ {

View File

@@ -1,8 +1,8 @@
<?php <?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddBatchUuidColumnToActivityLogTable extends Migration class AddBatchUuidColumnToActivityLogTable extends Migration
{ {

View File

@@ -2,8 +2,8 @@
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
{ {
@@ -18,7 +18,7 @@ return new class extends Migration
try { try {
// MySQL automatically names check constraints in different ways, // MySQL automatically names check constraints in different ways,
// so we attempt a generic drop and ignore failures. // so we attempt a generic drop and ignore failures.
DB::statement("ALTER TABLE `payment_providers` DROP CHECK `payment_providers.configuration`;"); DB::statement('ALTER TABLE `payment_providers` DROP CHECK `payment_providers.configuration`;');
} catch (\Throwable $e) { } catch (\Throwable $e) {
// ignore if constraint does not exist // ignore if constraint does not exist
} }
@@ -37,7 +37,7 @@ return new class extends Migration
AND pg_get_constraintdef(pg_constraint.oid) LIKE '%configuration%json%'; AND pg_get_constraintdef(pg_constraint.oid) LIKE '%configuration%json%';
"); ");
if (!empty($constraint->conname)) { if (! empty($constraint->conname)) {
DB::statement("ALTER TABLE payment_providers DROP CONSTRAINT {$constraint->conname};"); DB::statement("ALTER TABLE payment_providers DROP CONSTRAINT {$constraint->conname};");
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -2,9 +2,9 @@
namespace Database\Seeders; namespace Database\Seeders;
use stdClass;
use App\Models\Meta; use App\Models\Meta;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use stdClass;
class MetaSeeder extends Seeder class MetaSeeder extends Seeder
{ {

View File

@@ -28,15 +28,15 @@ class PaymentProviderSeeder extends Seeder
'secret_key' => env('STRIPE_SECRET') ?: 'sk_test_placeholder', 'secret_key' => env('STRIPE_SECRET') ?: 'sk_test_placeholder',
'publishable_key' => env('STRIPE_PUBLISHABLE_KEY') ?: 'pk_test_placeholder', 'publishable_key' => env('STRIPE_PUBLISHABLE_KEY') ?: 'pk_test_placeholder',
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET') ?: 'whsec_placeholder', 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET') ?: 'whsec_placeholder',
'webhook_url' => env('APP_URL', 'https://example.com') . '/webhook/stripe', 'webhook_url' => env('APP_URL', 'https://example.com').'/webhook/stripe',
'success_url' => env('APP_URL', 'https://example.com') . '/payment/success', 'success_url' => env('APP_URL', 'https://example.com').'/payment/success',
'cancel_url' => env('APP_URL', 'https://example.com') . '/payment/cancel', 'cancel_url' => env('APP_URL', 'https://example.com').'/payment/cancel',
'currency' => env('CASHIER_CURRENCY', 'USD'), 'currency' => env('CASHIER_CURRENCY', 'USD'),
], ],
'supports_recurring' => true, 'supports_recurring' => true,
'supports_one_time' => true, 'supports_one_time' => true,
'supported_currencies' => [ 'supported_currencies' => [
'USD' => 'US Dollar' 'USD' => 'US Dollar',
], ],
'fee_structure' => [ 'fee_structure' => [
'fixed_fee' => '0.50', 'fixed_fee' => '0.50',
@@ -56,14 +56,14 @@ class PaymentProviderSeeder extends Seeder
'api_key' => env('LEMON_SQUEEZY_API_KEY', 'lsk_...'), 'api_key' => env('LEMON_SQUEEZY_API_KEY', 'lsk_...'),
'store_id' => env('LEMON_SQUEEZY_STORE_ID', '...'), 'store_id' => env('LEMON_SQUEEZY_STORE_ID', '...'),
'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET', 'whsec_...'), 'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET', 'whsec_...'),
'webhook_url' => env('APP_URL') . '/webhook/lemon-squeezy', 'webhook_url' => env('APP_URL').'/webhook/lemon-squeezy',
'success_url' => env('APP_URL') . '/payment/success', 'success_url' => env('APP_URL').'/payment/success',
'cancel_url' => env('APP_URL') . '/payment/cancel', 'cancel_url' => env('APP_URL').'/payment/cancel',
], ],
'supports_recurring' => true, 'supports_recurring' => true,
'supports_one_time' => true, 'supports_one_time' => true,
'supported_currencies' => [ 'supported_currencies' => [
'USD' => 'US Dollar' 'USD' => 'US Dollar',
], ],
'fee_structure' => [ 'fee_structure' => [
'fixed_fee' => '0.50', 'fixed_fee' => '0.50',
@@ -86,9 +86,9 @@ class PaymentProviderSeeder extends Seeder
'sandbox_api_key' => env('POLAR_SANDBOX_API_KEY', 'pol_test_...'), 'sandbox_api_key' => env('POLAR_SANDBOX_API_KEY', 'pol_test_...'),
'sandbox_webhook_secret' => env('POLAR_SANDBOX_WEBHOOK_SECRET', 'whsec_test_...'), 'sandbox_webhook_secret' => env('POLAR_SANDBOX_WEBHOOK_SECRET', 'whsec_test_...'),
'access_token' => env('POLAR_ACCESS_TOKEN', 'polar_...'), 'access_token' => env('POLAR_ACCESS_TOKEN', 'polar_...'),
'webhook_url' => env('APP_URL') . '/webhook/polar', 'webhook_url' => env('APP_URL').'/webhook/polar',
'success_url' => env('APP_URL') . '/payment/success', 'success_url' => env('APP_URL').'/payment/success',
'cancel_url' => env('APP_URL') . '/payment/cancel', 'cancel_url' => env('APP_URL').'/payment/cancel',
], ],
'supports_recurring' => true, 'supports_recurring' => true,
'supports_one_time' => 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_...'), 'sandbox_merchant_api_key' => env('OXAPAY_SANDBOX_MERCHANT_API_KEY', 'merchant_sb_...'),
'payout_api_key' => env('OXAPAY_PAYOUT_API_KEY', 'payout_...'), 'payout_api_key' => env('OXAPAY_PAYOUT_API_KEY', 'payout_...'),
'sandbox_payout_api_key' => env('OXAPAY_SANDBOX_PAYOUT_API_KEY', 'payout_sb_...'), 'sandbox_payout_api_key' => env('OXAPAY_SANDBOX_PAYOUT_API_KEY', 'payout_sb_...'),
'callback_url' => env('OXAPAY_CALLBACK_URL', env('APP_URL') . '/webhook/oxapay'), 'callback_url' => env('OXAPAY_CALLBACK_URL', env('APP_URL').'/webhook/oxapay'),
'success_url' => env('APP_URL') . '/payment/success', 'success_url' => env('APP_URL').'/payment/success',
'cancel_url' => env('APP_URL') . '/payment/cancel', 'cancel_url' => env('APP_URL').'/payment/cancel',
'sandbox' => env('OXAPAY_SANDBOX', true), 'sandbox' => env('OXAPAY_SANDBOX', true),
'currency' => env('OXAPAY_CURRENCY', 'USD'), // string 'currency' => env('OXAPAY_CURRENCY', 'USD'), // string
'lifetime' => env('OXAPAY_LIFETIME', 30), // integer · min: 15 · max: 2880 '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'), 'exchange_rate_provider' => env('CRYPTO_EXCHANGE_RATE_PROVIDER', 'coingecko'),
'coingecko_api_key' => env('COINGECKO_API_KEY'), 'coingecko_api_key' => env('COINGECKO_API_KEY'),
'blockchair_api_key' => env('BLOCKCHAIR_API_KEY'), 'blockchair_api_key' => env('BLOCKCHAIR_API_KEY'),
'webhook_url' => env('APP_URL') . '/webhook/crypto', 'webhook_url' => env('APP_URL').'/webhook/crypto',
'success_url' => env('APP_URL') . '/payment/success', 'success_url' => env('APP_URL').'/payment/success',
'cancel_url' => env('APP_URL') . '/payment/cancel', 'cancel_url' => env('APP_URL').'/payment/cancel',
'supported_wallets' => [ 'supported_wallets' => [
'btc' => ['bitcoin', 'lightning'], 'btc' => ['bitcoin', 'lightning'],
'eth' => ['ethereum', 'erc20'], 'eth' => ['ethereum', 'erc20'],
@@ -227,6 +227,7 @@ class PaymentProviderSeeder extends Seeder
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->command->error("❌ Error seeding provider {$providerData['name']}: {$e->getMessage()}"); $this->command->error("❌ Error seeding provider {$providerData['name']}: {$e->getMessage()}");
continue; continue;
} }
} }

61
docker-compose.yml Normal file
View File

@@ -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:

View File

@@ -11,71 +11,11 @@ $kernel->bootstrap();
set_time_limit(0); set_time_limit(0);
$newTimezone = 'Europe/London'; try {
date_default_timezone_set($newTimezone); // Run the new Artisan command fallback
$exitCode = $kernel->call('mailbox:clean');
$imapDB = json_decode(config('app.settings.imap_settings') ?: '{}', true); $output = $kernel->output();
echo $output;
// Mailbox credentials } catch (\Exception $e) {
$hostname = '{'.($imapDB['host'] ?? 'localhost').':'.($imapDB['port'] ?? '993').'/ssl}INBOX'; echo 'Error running Artisan command: '.$e->getMessage();
$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());
} }
// 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);

162
laravel_webhook_handover.md Normal file
View File

@@ -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 <CONFIGURED_WEBHOOK_SECRET>` (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>HTML content...</html>"
}
```
*(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.

View File

@@ -3,4 +3,4 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; 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');

View File

@@ -1,7 +1,7 @@
<?php <?php
use App\Livewire\Actions\Logout;
use App\Http\Controllers\Auth\VerifyEmailController; use App\Http\Controllers\Auth\VerifyEmailController;
use App\Livewire\Actions\Logout;
use App\Livewire\Auth\ConfirmPassword; use App\Livewire\Auth\ConfirmPassword;
use App\Livewire\Auth\ForgotPassword; use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Auth\Login; use App\Livewire\Auth\Login;

View File

@@ -1,27 +1,22 @@
<?php <?php
use App\Models\Log;
use App\Models\Email; use App\Models\Email;
use App\Models\Log;
use App\Models\Ticket; use App\Models\Ticket;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function (): void { Artisan::command('inspire', function (): void {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
// Schedule::call(function () { // Schedule the new commands instead of closures
// Email::fetchProcessStoreEmail(); Schedule::command('emails:fetch')->everyMinute();
// })->everyMinute(); Schedule::command('attachments:clean')->daily();
Schedule::command('mailbox:clean')->everyTwoHours();
Schedule::call(function (): void {
Email::deleteBulkAttachments();
})->daily();
// Schedule::call(function () {
// Email::deleteBulkMailboxes();
// })->everyMinute();
// Keep other necessary schedules
Schedule::call(function (): void { Schedule::call(function (): void {
Email::deleteMessagesFromDB(); Email::deleteMessagesFromDB();
})->everyTwoHours(); })->everyTwoHours();
@@ -30,10 +25,7 @@ Schedule::call(function (): void {
Log::deleteLogsFromDB(); Log::deleteLogsFromDB();
})->everyThreeHours(); })->everyThreeHours();
Schedule::call(function (): void { // Preserve existing commands
Email::cleanMailbox();
});
Artisan::command('cleanMail', function (): void { Artisan::command('cleanMail', function (): void {
$this->comment(Email::cleanMailbox()); $this->comment(Email::cleanMailbox());
}); });

View File

@@ -1,7 +1,6 @@
<?php <?php
use App\Http\Controllers\AppController; use App\Http\Controllers\AppController;
use App\Http\Controllers\ImpersonationController; use App\Http\Controllers\ImpersonationController;
use App\Http\Controllers\WebhookController; use App\Http\Controllers\WebhookController;
use App\Http\Middleware\CheckPageSlug; use App\Http\Middleware\CheckPageSlug;
@@ -22,11 +21,14 @@ use App\Livewire\Settings\Billing;
use App\Livewire\Settings\Password; use App\Livewire\Settings\Password;
use App\Livewire\Settings\Profile; use App\Livewire\Settings\Profile;
use App\Models\Email; use App\Models\Email;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/health', function () {
return response('OK', 200);
});
Route::get('/', Home::class)->name('home'); Route::get('/', Home::class)->name('home');
Route::get('/mailbox', Mailbox::class)->name('mailbox'); Route::get('/mailbox', Mailbox::class)->name('mailbox');
Route::get('/mailbox/{email?}', [AppController::class, 'mailbox'])->name('mailboxFromURL'); 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/compose-email', Dashboard::class)->name('dashboard.compose');
Route::get('dashboard/support', Support::class)->name('dashboard.support'); 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) // 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/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/cancel', [Dashboard::class, 'paymentStatus'])->name('checkout.cancel')->defaults('status', 'cancel');
Route::get('dashboard/billing', fn () => auth()->user()->redirectToBillingPortal(route('dashboard')))->name('billing'); 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 // Impersonation Routes
Route::prefix('impersonation')->name('impersonation.')->group(function (): void { Route::prefix('impersonation')->name('impersonation.')->group(function (): void {
Route::post('/stop', [ImpersonationController::class, 'stop'])->name('stop'); Route::post('/stop', [ImpersonationController::class, 'stop'])->name('stop');

View File

@@ -2,11 +2,11 @@
namespace Tests\Concerns; namespace Tests\Concerns;
use Exception;
use Illuminate\Support\Collection;
use App\Models\Blog; use App\Models\Blog;
use App\Models\Menu; use App\Models\Menu;
use App\Models\Plan; use App\Models\Plan;
use Exception;
use Illuminate\Support\Collection;
trait LoadsApplicationData trait LoadsApplicationData
{ {
@@ -64,7 +64,7 @@ trait LoadsApplicationData
try { try {
$menus = cache()->remember('app_menus', now()->addHours(6), Menu::all(...)); $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(...)); $plans = cache()->remember('app_plans', now()->addHours(6), Plan::all(...));
} catch (Exception) { } catch (Exception) {

View File

@@ -2,30 +2,30 @@
namespace Tests\Feature\Filament; namespace Tests\Feature\Filament;
use App\Filament\Resources\TicketResource\Pages\ListTickets; use App\Filament\Resources\BlogResource;
use App\Filament\Resources\TicketResource\Pages\EditTicket; use App\Filament\Resources\BlogResource\Pages\CreateBlog;
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\Pages\EditBlog; 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\CreateCategory;
use App\Filament\Resources\CategoryResource\Pages\EditCategory; 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\CreatePage;
use App\Filament\Resources\PageResource\Pages\EditPage; use App\Filament\Resources\PageResource\Pages\EditPage;
use App\Filament\Resources\MenuResource\Pages\ListMenus; use App\Filament\Resources\PageResource\Pages\ListPages;
use App\Filament\Resources\MenuResource\Pages\CreateMenu;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Filament\Resources\UserResource;
use App\Filament\Resources\PlanResource; 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\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\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\Blog;
use App\Models\Category; use App\Models\Category;
use App\Models\Menu; use App\Models\Menu;
@@ -40,6 +40,7 @@ use Tests\TestCase;
class ResourcesTest extends TestCase class ResourcesTest extends TestCase
{ {
public $adminUser; public $adminUser;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -2,12 +2,12 @@
namespace Tests\Feature\Filament; 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;
use App\Filament\Resources\UserResource\Pages\CreateUser; use App\Filament\Resources\UserResource\Pages\CreateUser;
use App\Filament\Resources\UserResource\Pages\EditUser; use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers; 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\Log;
use App\Models\User; use App\Models\User;
use Livewire\Livewire; use Livewire\Livewire;
@@ -16,6 +16,7 @@ use Tests\TestCase;
class UserResourceTest extends TestCase class UserResourceTest extends TestCase
{ {
public $adminUser; public $adminUser;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -13,6 +13,7 @@ use Tests\TestCase;
class LoginTest extends TestCase class LoginTest extends TestCase
{ {
public $user; public $user;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -12,6 +12,7 @@ use Tests\TestCase;
class DashboardTest extends TestCase class DashboardTest extends TestCase
{ {
public $user; public $user;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -2,13 +2,13 @@
namespace Tests\Feature\Livewire; namespace Tests\Feature\Livewire;
use App\Livewire\Home;
use App\Livewire\Frontend\Mailbox; use App\Livewire\Frontend\Mailbox;
use Exception; use App\Livewire\Home;
use App\Models\Blog;
use App\Livewire\ListBlog; use App\Livewire\ListBlog;
use App\Models\Blog;
use App\Models\Page; use App\Models\Page;
use App\Models\ZEmail; use App\Models\ZEmail;
use Exception;
use Illuminate\Support\Facades\Cookie; use Illuminate\Support\Facades\Cookie;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;

View File

@@ -1,7 +1,7 @@
<?php <?php
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -30,7 +30,7 @@ pest()->extend(TestCase::class)
| |
*/ */
expect()->extend('toBeOne', fn() => $this->toBe(1)); expect()->extend('toBeOne', fn () => $this->toBe(1));
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -9,6 +9,7 @@ use Tests\TestCase;
class ActivationKeyTest extends TestCase class ActivationKeyTest extends TestCase
{ {
public $user; public $user;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -2,17 +2,19 @@
namespace Tests\Unit\Models; namespace Tests\Unit\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use App\Models\Blog; use App\Models\Blog;
use App\Models\Category; use App\Models\Category;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Tests\TestCase; use Tests\TestCase;
class BlogTest extends TestCase class BlogTest extends TestCase
{ {
private User|Collection $user; private User|Collection $user;
public $category; public $category;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -2,9 +2,9 @@
namespace Tests\Unit\Models; namespace Tests\Unit\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\Blog; use App\Models\Blog;
use App\Models\Category; use App\Models\Category;
use Illuminate\Database\Eloquent\Model;
use Tests\TestCase; use Tests\TestCase;
class CategoryTest extends TestCase class CategoryTest extends TestCase

View File

@@ -2,10 +2,10 @@
namespace Tests\Unit\Models; namespace Tests\Unit\Models;
use Illuminate\Support\Facades\Date;
use App\Models\Email; use App\Models\Email;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Date;
use Tests\TestCase; use Tests\TestCase;
class EmailTest extends TestCase class EmailTest extends TestCase

View File

@@ -9,6 +9,7 @@ use Tests\TestCase;
class LogTest extends TestCase class LogTest extends TestCase
{ {
public $user; public $user;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -2,13 +2,14 @@
namespace Tests\Unit\Models; namespace Tests\Unit\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\Plan; use App\Models\Plan;
use Illuminate\Database\Eloquent\Model;
use Tests\TestCase; use Tests\TestCase;
class PlanTest extends TestCase class PlanTest extends TestCase
{ {
public $planData; public $planData;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -10,6 +10,7 @@ use Tests\TestCase;
class PremiumEmailTest extends TestCase class PremiumEmailTest extends TestCase
{ {
public $user; public $user;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -2,17 +2,19 @@
namespace Tests\Unit\Models; namespace Tests\Unit\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketResponse; use App\Models\TicketResponse;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Tests\TestCase; use Tests\TestCase;
class TicketResponseTest extends TestCase class TicketResponseTest extends TestCase
{ {
public $user; public $user;
public $ticket; public $ticket;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -2,17 +2,19 @@
namespace Tests\Unit\Models; namespace Tests\Unit\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketResponse; use App\Models\TicketResponse;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Tests\TestCase; use Tests\TestCase;
class TicketTest extends TestCase class TicketTest extends TestCase
{ {
public $user; public $user;
public $ticketData; public $ticketData;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -9,6 +9,7 @@ use Tests\TestCase;
class UsageLogTest extends TestCase class UsageLogTest extends TestCase
{ {
public $user; public $user;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -2,25 +2,26 @@
namespace Tests\Unit\Models; 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\Log;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\UsageLog; use App\Models\UsageLog;
use App\Models\User; use App\Models\User;
use Carbon\Carbon;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel; 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; use Tests\TestCase;
class UserTest extends TestCase class UserTest extends TestCase
{ {
public $user; public $user;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();

View File

@@ -2,9 +2,9 @@
namespace Tests\Unit; namespace Tests\Unit;
use Exception;
use App\Http\Controllers\WebhookController; use App\Http\Controllers\WebhookController;
use App\NotifyMe; use App\NotifyMe;
use Exception;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -19,6 +19,7 @@ class TestNotifier
class NotifyMeTest extends TestCase class NotifyMeTest extends TestCase
{ {
public $notifier; public $notifier;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@@ -42,7 +43,7 @@ class NotifyMeTest extends TestCase
$result = $this->notifier->sendTelegramNotification('Test message'); $result = $this->notifier->sendTelegramNotification('Test message');
$this->assertTrue($result); $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['chat_id'] === 'test_chat_id' &&
$request['text'] === 'Test message' && $request['text'] === 'Test message' &&
$request['parse_mode'] === 'HTML'); $request['parse_mode'] === 'HTML');
@@ -133,7 +134,7 @@ class NotifyMeTest extends TestCase
$htmlMessage = '<b>Bold text</b> and <i>italic text</i>'; $htmlMessage = '<b>Bold text</b> and <i>italic text</i>';
$this->notifier->sendTelegramNotification($htmlMessage); $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); $request['text'] === $htmlMessage);
} }