Files
Frank Schwenk 2440fbf974 fix: spam-friendly mail headers; docs: monitor.php token todo
Use MTA-style Message-ID and display names on From/To to avoid
SpamAssassin MSGID_RANDY and PDS_NAKED_TO_NUMERO. Add todo/12 for
HTTP token gate on monitor.php (CLI cron unchanged).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 10:48:37 +02:00

183 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
/**
* Send mail via SMTP (AUTH LOGIN + STARTTLS).
* Uses SMTP_* and SMTP_FROM from private/.env.
*/
function sendMail(string $to, string $subject, string $body, ?string $from = null): void
{
$host = env('SMTP_HOST');
$port = (int) (env('SMTP_PORT') ?: 587);
$user = env('SMTP_USER');
$pass = env('SMTP_PASSWORD');
$from = $from ?? env('SMTP_FROM') ?? $user;
if (!$host || !$user || !$pass || !$from) {
throw new RuntimeException('SMTP not configured — set SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM in .env');
}
$encryption = strtolower(env('SMTP_ENCRYPTION') ?? 'tls');
$socket = connectSmtp($host, $port, $encryption);
$fromHeader = $from ?? env('SMTP_FROM') ?? $user;
// Envelope sender must be the authenticated mailbox (aliases are header-only on Plesk).
$envelopeFrom = $user;
try {
expectCode($socket, 220);
sendCommand($socket, 'EHLO ' . gethostname(), 250);
if ($encryption === 'tls') {
sendCommand($socket, 'STARTTLS', 220);
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException('STARTTLS failed');
}
sendCommand($socket, 'EHLO ' . gethostname(), 250);
}
sendCommand($socket, 'AUTH LOGIN', 334);
sendCommand($socket, base64_encode($user), 334);
sendCommand($socket, base64_encode($pass), 235);
sendCommand($socket, 'MAIL FROM:<' . $envelopeFrom . '>', 250);
sendCommand($socket, 'RCPT TO:<' . bareMailAddress($to) . '>', 250);
sendCommand($socket, 'DATA', 354);
$message = buildMessage($fromHeader, $to, $subject, $body);
fwrite($socket, $message);
expectCode($socket, 250);
sendCommand($socket, 'QUIT', 221);
} finally {
fclose($socket);
}
}
/** @return resource */
function connectSmtp(string $host, int $port, string $encryption)
{
$target = $encryption === 'ssl'
? "ssl://{$host}:{$port}"
: "tcp://{$host}:{$port}";
$socket = @stream_socket_client(
$target,
$errno,
$errstr,
30,
STREAM_CLIENT_CONNECT,
stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]])
);
if ($socket === false) {
throw new RuntimeException("SMTP connect failed: {$errstr} ({$errno})");
}
stream_set_timeout($socket, 30);
return $socket;
}
/** @param resource $socket */
function sendCommand($socket, string $command, int $expectedCode): void
{
fwrite($socket, $command . "\r\n");
expectCode($socket, $expectedCode);
}
/** @param resource $socket */
function expectCode($socket, int $expectedCode): void
{
$response = readResponse($socket);
$code = (int) substr($response, 0, 3);
if ($code !== $expectedCode) {
throw new RuntimeException("SMTP error (expected {$expectedCode}): {$response}");
}
}
/** @param resource $socket */
function readResponse($socket): string
{
$response = '';
while (($line = fgets($socket, 515)) !== false) {
$response .= $line;
if (isset($line[3]) && $line[3] === ' ') {
break;
}
}
return trim($response);
}
function bareMailAddress(string $address): string
{
$address = trim($address);
if (preg_match('/<([^>]+)>/', $address, $matches)) {
return trim($matches[1]);
}
return $address;
}
function encodeDisplayName(string $name): string
{
if (preg_match('/[\r\n"]/', $name)) {
throw new InvalidArgumentException('Invalid display name');
}
if (preg_match('/^[A-Za-z0-9 !\'.\-]+$/', $name)) {
return $name;
}
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $name) . '"';
}
function formatMailAddress(string $email, string $displayName): string
{
$email = bareMailAddress($email);
return encodeDisplayName($displayName) . ' <' . $email . '>';
}
function buildMessage(string $from, string $to, string $subject, string $body): string
{
$domain = env('DOMAIN', '0012.de');
$date = gmdate('D, d M Y H:i:s') . ' +0000';
// MTA-style ID (time + pid) — avoids SpamAssassin MSGID_RANDY on random hex IDs.
$messageId = sprintf('<%s.%s@%s>', gmdate('YmdHis'), getmypid(), $domain);
$fromHeader = formatMailAddress($from, env('SMTP_FROM_NAME', '0012 Monitoring'));
$toHeader = formatMailAddress($to, env('MAIL_TO_NAME', 'Alert Recipient'));
$headers = [
"From: {$fromHeader}",
"To: {$toHeader}",
"Subject: {$subject}",
"Date: {$date}",
"Message-ID: {$messageId}",
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
];
return implode("\r\n", $headers) . "\r\n\r\n" . $body . "\r\n.\r\n";
}
function sendAlert(string $subject, string $body): void
{
$to = env('ALERT_EMAIL');
if (!$to) {
throw new RuntimeException('ALERT_EMAIL not set in .env');
}
sendMail($to, $subject, $body);
}