fix(IMAP): оптимизация обработки email для предотвращения memory exhaustion

Корневая проблема: библиотека webklex/php-imap загружала все тела писем
по умолчанию, что приводило к исчерпанию 256MB памяти при 50+ письмах.

Решение (3 уровня):

Уровень 1 - Конфигурация:
- config/imap.php: добавлены fetch_body=false, fetch_flags=false, fetch_limit=20
- php.ini: увеличен memory_limit с 256M до 1G, добавлены OPcache настройки

Уровень 2 - Оптимизация кода:
- FetchUnreadEmailsTask: setFetchBody(false), лимиты, MemoryAwareTrait
- FetchEmailNewsAction: batch-обработка (10 писем), gc_collect_cycles()
- DownloadAttachmentsTask: lazy load вложений, extension whitelist (security)
- ConnectToImapTask: MemoryAwareTrait для мониторинга

Уровень 3 - Инфраструктура:
- ProcessEmailNewsJob: queue job для асинхронной обработки (3 попытки, 300s timeout)
- FetchEmailNewsCommand: поддержка --async режима
- MemoryAwareTrait: reusable трейт для мониторинга памяти

Security fixes:
- Extension whitelist для предотвращения path traversal
- Убраны реальные email из config/imap.php
- Добавлена проверка на null message объект

Docs:
- README_EMAIL_OPTIMIZATION.md: полная документация по оптимизации

Fixes: Allowed memory size of 268435456 bytes exhausted

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
F4ilji
2026-04-06 22:08:03 +05:00
co-authored by Qwen-Coder
parent 4b071d9ef2
commit c115a5c7a7
14 changed files with 2143 additions and 47 deletions
@@ -2,13 +2,18 @@
namespace App\Containers\Dashboard\Commands;
use App\Containers\Dashboard\Actions\FetchEmailNewsAction;
use App\Containers\Dashboard\Actions\EmailNews\FetchEmailNewsAction;
use App\Containers\Dashboard\Exceptions\EmailFetchException;
use App\Containers\Dashboard\Jobs\ProcessEmailNewsJob;
use App\Ship\Abstracts\Commands\ConsoleCommand;
use Illuminate\Support\Facades\Log;
/**
* Artisan команда для получения новостей из Email
*
* OPTIMIZATION: Поддерживает два режима:
* 1. Синхронный (по умолчанию) — для backward compatibility
* 2. Асинхронный (--async) — dispatch в queue worker (рекомендуется)
*/
class FetchEmailNewsCommand extends ConsoleCommand
{
@@ -17,9 +22,11 @@ class FetchEmailNewsCommand extends ConsoleCommand
*
* @var string
*/
protected $signature = 'email:fetch-news
protected $signature = 'email:fetch-news
{--force : Принудительный запуск, даже если отключено в конфиге}
{--log : Выводить подробный лог в консоль}';
{--log : Выводить подробный лог в консоль}
{--async : Отправить задачу в очередь (рекомендуется для production)}
{--queue= : Имя очереди (по умолчанию "default")}';
/**
* The console command description.
@@ -39,6 +46,8 @@ class FetchEmailNewsCommand extends ConsoleCommand
$force = $this->option('force');
$verbose = $this->option('log');
$async = $this->option('async');
$queue = $this->option('queue') ?? 'default';
// Проверяем, включена ли функция
if (!config('email-news.enabled', true) && !$force) {
@@ -48,50 +57,13 @@ class FetchEmailNewsCommand extends ConsoleCommand
}
try {
$result = $fetchEmailNewsAction->run();
// Вывод результатов
$this->newLine();
$this->line('📊 Результаты обработки:');
$this->table(
['Метрика', 'Значение'],
[
['Обработано писем', $result['processed_emails']],
['Пропущено (не редактор)', $result['skipped_emails']],
['Создано новостей', $result['created_posts']],
['Ошибок', count($result['errors'])],
]
);
// Вывод созданных постов
if (!empty($result['posts'])) {
$this->newLine();
$this->info('✅ Созданные новости:');
foreach ($result['posts'] as $post) {
$status = $post->status instanceof \BackedEnum ? $post->status->value : $post->status;
$this->line("{$post->title} (ID: {$post->id}, статус: {$status})");
}
// OPTIMIZATION: Асинхронный режим через queue worker
if ($async) {
return $this->handleAsync($queue, $verbose);
}
// Вывод ошибок
if (!empty($result['errors'])) {
$this->newLine();
$this->error('❌ Ошибки:');
foreach ($result['errors'] as $error) {
$this->warn("{$error['email_subject']}: {$error['error']}");
}
}
// Подробный лог
if ($verbose) {
$this->newLine();
$this->info('📝 Детальный лог доступен в storage/logs/laravel.log');
}
$this->newLine();
$this->info('✅ Завершено!');
return 0;
// Синхронный режим (backward compatibility)
return $this->handleSync($fetchEmailNewsAction, $verbose);
} catch (EmailFetchException $e) {
$this->error('❌ Ошибка Email: ' . $e->getMessage());
Log::error('[FetchEmailNewsCommand] EmailFetchException', [
@@ -112,4 +84,102 @@ class FetchEmailNewsCommand extends ConsoleCommand
return 1;
}
}
/**
* Синхронная обработка (backward compatibility)
*
* @param FetchEmailNewsAction $fetchEmailNewsAction
* @param bool $verbose
* @return int
*/
private function handleSync(FetchEmailNewsAction $fetchEmailNewsAction, bool $verbose): int
{
$result = $fetchEmailNewsAction->run();
// Вывод результатов
$this->displayResults($result, $verbose);
return 0;
}
/**
* Асинхронная обработка через queue worker
*
* @param string $queue
* @param bool $verbose
* @return int
*/
private function handleAsync(string $queue, bool $verbose): int
{
$this->info("📤 Отправка задачи в очередь '{$queue}'...");
$job = ProcessEmailNewsJob::dispatch()->onQueue($queue);
$this->info('✅ Задача отправлена в очередь');
$this->line(" Job ID: {$job->getJobId()}");
$this->newLine();
$this->info('📝 Мониторинг выполнения:');
$this->line(" php artisan queue:monitor {$queue}");
$this->line(" tail -f storage/logs/laravel.log | grep ProcessEmailNewsJob");
if ($verbose) {
$this->newLine();
$this->info('️ Queue worker должен быть запущен:');
$this->line(' docker exec ntspi-php php artisan queue:work --queue=' . $queue);
}
return 0;
}
/**
* Вывод результатов обработки
*
* @param array $result
* @param bool $verbose
* @return void
*/
private function displayResults(array $result, bool $verbose): void
{
$this->newLine();
$this->line('📊 Результаты обработки:');
$this->table(
['Метрика', 'Значение'],
[
['Обработано писем', $result['processed_emails']],
['Пропущено (не редактор)', $result['skipped_emails']],
['Создано новостей', $result['created_posts']],
['Ошибок', count($result['errors'])],
['Batch-ей обработано', $result['batches_processed'] ?? 0],
['Пик памяти', $result['peak_memory'] ?? 'N/A'],
]
);
// Вывод созданных постов
if (!empty($result['posts'])) {
$this->newLine();
$this->info('✅ Созданные новости:');
foreach ($result['posts'] as $post) {
$status = $post->status instanceof \BackedEnum ? $post->status->value : $post->status;
$this->line("{$post->title} (ID: {$post->id}, статус: {$status})");
}
}
// Вывод ошибок
if (!empty($result['errors'])) {
$this->newLine();
$this->error('❌ Ошибки:');
foreach ($result['errors'] as $error) {
$this->warn("{$error['email_subject']}: {$error['error']}");
}
}
// Подробный лог
if ($verbose) {
$this->newLine();
$this->info('📝 Детальный лог доступен в storage/logs/laravel.log');
}
$this->newLine();
$this->info('✅ Завершено!');
}
}