feat(external): SmtpLivenessProbe — живость почты Yandex 360
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+65
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user