150 lines
5.3 KiB
PHP
150 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\MailProfile;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class TestMailProfileConnection implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public int $timeout = 30;
|
|
|
|
public function __construct(public int $mailProfileId) {}
|
|
|
|
public function handle(): void
|
|
{
|
|
$profile = MailProfile::find($this->mailProfileId);
|
|
if (! $profile) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->performSmtpAuthTest($profile);
|
|
$profile->forceFill([
|
|
'test_status' => 'success',
|
|
'test_checked_at' => now(),
|
|
'last_success_at' => now(),
|
|
'last_error_message' => null,
|
|
])->save();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('mail_profile.test_failed', [
|
|
'id' => $profile->id,
|
|
'host' => $profile->host,
|
|
'port' => $profile->port,
|
|
'encryption' => $profile->encryption,
|
|
// Intentionally NOT logging username/password
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$profile->forceFill([
|
|
'test_status' => 'failed',
|
|
'test_checked_at' => now(),
|
|
'last_error_at' => now(),
|
|
'last_error_message' => substr($e->getMessage(), 0, 400),
|
|
])->save();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform a real SMTP handshake + (optional) STARTTLS + AUTH LOGIN cycle.
|
|
* Throws on any protocol / auth failure.
|
|
*/
|
|
protected function performSmtpAuthTest(MailProfile $profile): void
|
|
{
|
|
$host = $profile->host;
|
|
$port = (int) $profile->port;
|
|
$encryption = strtolower((string) ($profile->encryption ?? ''));
|
|
$username = $profile->username;
|
|
$password = trim($profile->decryptPassword() ?? '');
|
|
if (app()->environment('local')) {
|
|
Log::debug('mail_profile.test.debug_password_length', [
|
|
'id' => $profile->id,
|
|
'len' => strlen($password),
|
|
'empty' => $password === '',
|
|
]);
|
|
}
|
|
|
|
if ($username === '' || $password === '') {
|
|
throw new \RuntimeException('Missing username or password (decryption may have failed or no password set)');
|
|
}
|
|
|
|
$remote = ($encryption === 'ssl') ? 'ssl://'.$host : $host;
|
|
$errno = 0; $errstr = '';
|
|
$socket = @fsockopen($remote, $port, $errno, $errstr, 15);
|
|
if (! $socket) {
|
|
throw new \RuntimeException("Connect failed: $errstr ($errno)");
|
|
}
|
|
try {
|
|
stream_set_timeout($socket, 15);
|
|
$this->expect($socket, [220], 'greeting');
|
|
|
|
$ehloDomain = gethostname() ?: 'localhost';
|
|
$this->command($socket, "EHLO $ehloDomain\r\n", [250], 'EHLO');
|
|
|
|
if ($encryption === 'tls') {
|
|
$this->command($socket, "STARTTLS\r\n", [220], 'STARTTLS');
|
|
if (! stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
throw new \RuntimeException('STARTTLS negotiation failed');
|
|
}
|
|
// Re-issue EHLO after TLS per RFC
|
|
$this->command($socket, "EHLO $ehloDomain\r\n", [250], 'post-STARTTLS EHLO');
|
|
}
|
|
|
|
// AUTH LOGIN flow
|
|
$this->command($socket, "AUTH LOGIN\r\n", [334], 'AUTH LOGIN');
|
|
$this->command($socket, base64_encode($username)."\r\n", [334], 'AUTH username');
|
|
$authResp = $this->command($socket, base64_encode($password)."\r\n", [235], 'AUTH password');
|
|
|
|
// Cleanly quit
|
|
$this->command($socket, "QUIT\r\n", [221], 'QUIT');
|
|
} finally {
|
|
try { fclose($socket); } catch (\Throwable) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a command and assert expected code(s). Returns the last response line.
|
|
*/
|
|
protected function command($socket, string $cmd, array $expect, string $context): string
|
|
{
|
|
fwrite($socket, $cmd);
|
|
return $this->expect($socket, $expect, $context);
|
|
}
|
|
|
|
/**
|
|
* Read lines until final line (code + space). Validate code.
|
|
*/
|
|
protected function expect($socket, array $expectedCodes, string $context): string
|
|
{
|
|
$lines = [];
|
|
while (true) {
|
|
$line = fgets($socket, 2048);
|
|
if ($line === false) {
|
|
throw new \RuntimeException("SMTP read failure during $context");
|
|
}
|
|
$lines[] = rtrim($line, "\r\n");
|
|
if (preg_match('/^(\d{3})([ -])/', $line, $m)) {
|
|
$code = (int) $m[1];
|
|
$more = $m[2] === '-';
|
|
if (! $more) {
|
|
if (! in_array($code, $expectedCodes, true)) {
|
|
throw new \RuntimeException("Unexpected SMTP code $code during $context: ".implode(' | ', $lines));
|
|
}
|
|
return $line;
|
|
}
|
|
}
|
|
if (count($lines) > 50) {
|
|
throw new \RuntimeException('SMTP response too verbose');
|
|
}
|
|
}
|
|
}
|
|
}
|