feat: SMTP mail stack and separate server.env deploy

Add mail.php with STARTTLS sending, test scripts, server.env split from
local .env, admin@ postbox docs, DKIM DNS support-ticket notes, and todo 11
for boka relay.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-06-11 23:19:03 +02:00
parent 04e1455d9f
commit 01de7ed4fb
16 changed files with 543 additions and 41 deletions
+21 -4
View File
@@ -3,8 +3,8 @@
declare(strict_types=1);
/**
* Load configuration from private/.env (outside document root).
* Called from httpdocs/*.php via require on private/config/bootstrap.php.
* Load configuration from private/server.env (outside document root).
* Local .env is never deployed — see server.env.example.
*/
function bootstrap(): array
@@ -15,12 +15,29 @@ function bootstrap(): array
return $config;
}
$envFile = dirname(__DIR__) . '/.env';
$config = loadEnvFile($envFile);
$config = loadEnvFile(envFilePath());
return $config;
}
function envFilePath(): string
{
$baseDir = dirname(__DIR__);
// Webspace: private/config/bootstrap.php → private/server.env
if (basename($baseDir) === 'private') {
return $baseDir . '/server.env';
}
// Local repo: .env on this machine (never deployed)
$local = $baseDir . '/.env';
if (is_readable($local)) {
return $local;
}
return $baseDir . '/server.env';
}
function env(string $key, ?string $default = null): ?string
{
$value = bootstrap()[$key] ?? null;
+147
View File
@@ -0,0 +1,147 @@
<?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:<' . $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 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';
$messageId = sprintf('<%s@%s>', bin2hex(random_bytes(8)), $domain);
$headers = [
"From: {$from}",
"To: {$to}",
"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);
}