feat(external): SmtpLivenessProbe — живость почты Yandex 360

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-02 07:50:16 +03:00
parent 9fd4459e2f
commit 5e8e58d1d1
3 changed files with 99 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Живость почты: TCP/TLS-connect к SMTP-порту Yandex 360 + чтение приветственного
* баннера (должен начинаться с «220»). Без логина/отправки денег/квоты не тратит.
* Соединитель инъектируется (тестируемость): возвращает первую строку баннера или бросает.
*/
class SmtpLivenessProbe implements LivenessProbe
{
/** @var (callable():string)|null */
private $connector;
/** @param (callable():string)|null $connector фейковый соединитель для тестов */
public function __construct(?callable $connector = null)
{
$this->connector = $connector;
}
public function serviceKey(): string
{
return 'email';
}
public function check(): LivenessReading
{
try {
$banner = ($this->connector ?? $this->defaultConnector())();
if (! str_starts_with(ltrim($banner), '220')) {
return LivenessReading::down('email', 'SMTP-баннер не 220: '.mb_substr(trim($banner), 0, 120));
}
return LivenessReading::alive('email', 'SMTP отвечает');
} catch (\Throwable $e) {
return LivenessReading::down('email', $e->getMessage());
}
}
/** @return callable():string */
private function defaultConnector(): callable
{
return function (): string {
$host = (string) config('services.smtp_probe.host');
$port = (int) config('services.smtp_probe.port');
$timeout = (int) config('services.smtp_probe.timeout', 5);
// 465 — implicit TLS; ssl:// нужен на connect.
$scheme = $port === 465 ? 'ssl://' : 'tcp://';
$fp = @stream_socket_client($scheme.$host.':'.$port, $errno, $errstr, $timeout);
if ($fp === false) {
throw new \RuntimeException($errstr !== '' ? $errstr : 'Connection refused (errno '.$errno.')');
}
try {
stream_set_timeout($fp, $timeout);
$line = fgets($fp, 512);
return $line === false ? '' : $line;
} finally {
fclose($fp);
}
};
}
}
+8
View File
@@ -95,6 +95,14 @@ return [
'amber_floor_rub' => (int) env('YC_AMBER_FLOOR_RUB', 5000),
],
// Healthcheck доступности SMTP (Yandex 360) для плитки внешних сервисов.
// Только connect+баннер, без логина/отправки. Дефолты — под Yandex 360.
'smtp_probe' => [
'host' => env('SMTP_PROBE_HOST', env('MAIL_HOST', 'smtp.yandex.ru')),
'port' => (int) env('SMTP_PROBE_PORT', 465),
'timeout' => (int) env('SMTP_PROBE_TIMEOUT', 5),
],
// G7-A: клиентская «Помощь».
'support' => [
'email' => env('SUPPORT_EMAIL', 'support@liderra.ru'),
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Services\External\SmtpLivenessProbe;
it('зелёный, когда сокет открылся и вернул баннер 220', function () {
$probe = new SmtpLivenessProbe(fn () => "220 smtp.yandex.ru ESMTP\r\n");
$r = $probe->check();
expect($r->serviceKey)->toBe('email');
expect($r->light)->toBe('green');
});
it('красный, когда соединитель бросил (порт недоступен)', function () {
$probe = new SmtpLivenessProbe(function () {
throw new RuntimeException('Connection refused');
});
$r = $probe->check();
expect($r->light)->toBe('red');
expect($r->detail)->toContain('refused');
});
it('красный, когда баннер не 220', function () {
$probe = new SmtpLivenessProbe(fn () => "554 blocked\r\n");
expect($probe->check()->light)->toBe('red');
});