353 lines
15 KiB
PHP
Executable File
353 lines
15 KiB
PHP
Executable File
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App;
|
|
|
|
final class Mailer
|
|
{
|
|
private string $logFile;
|
|
private bool $logCleared = false;
|
|
|
|
public function __construct(private App $app)
|
|
{
|
|
$base = dirname(__DIR__, 2);
|
|
$this->logFile = $base . '/debug/mailer_debug.log';
|
|
}
|
|
|
|
private function log(string $msg, array $ctx = []): void
|
|
{
|
|
if (!defined('APP_DEBUG') || APP_DEBUG !== true) {
|
|
return;
|
|
}
|
|
$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg;
|
|
if ($ctx) {
|
|
$line .= ' ' . json_encode($ctx, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
}
|
|
$line .= "\n";
|
|
$dir = dirname($this->logFile);
|
|
if (!is_dir($dir)) {
|
|
@mkdir($dir, 0775, true);
|
|
}
|
|
// For clarity keep only the latest run in the log: truncate once per request
|
|
if ($this->logCleared === false) {
|
|
@file_put_contents($this->logFile, '');
|
|
$this->logCleared = true;
|
|
}
|
|
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
|
}
|
|
|
|
private function templates(): array
|
|
{
|
|
$env = $this->app->config()->env;
|
|
$root = __DIR__ . '/../../config/emailtemplates.php';
|
|
$envPath = __DIR__ . "/../../config/{$env}/emailtemplates.php";
|
|
$file = is_file($root) ? $root : $envPath;
|
|
$emailtemplates = [];
|
|
if (is_file($file)) {
|
|
/** @noinspection PhpIncludeInspection */
|
|
include $file; // populates $emailtemplates variable from included file
|
|
}
|
|
return is_array($emailtemplates ?? null) ? $emailtemplates : [];
|
|
}
|
|
|
|
private function renderTemplate(string $key, array $vars): array
|
|
{
|
|
$templates = $this->templates();
|
|
$id = $templates[$key] ?? $key;
|
|
$this->log('template_resolved_id', ['key' => $key, 'id' => $id]);
|
|
|
|
$apiBase = getenv('EMAILTEMPLATE_API_BASE') ?: '';
|
|
$apiToken = getenv('EMAILTEMPLATE_API_TOKEN') ?: '';
|
|
|
|
if ($apiBase && $apiToken) {
|
|
$payload = [
|
|
'template' => $id,
|
|
'placeholders' => $vars,
|
|
];
|
|
$payload['token'] = $apiToken;
|
|
|
|
$payloadForLog = $payload;
|
|
$payloadForLog['token'] = '[hidden length ' . strlen((string)$apiToken) . ']';
|
|
$this->log('template_api_request_payload', [
|
|
'url' => $apiBase,
|
|
'payload' => $payloadForLog,
|
|
]);
|
|
|
|
$this->log('template_api_request', ['template' => $id, 'placeholders' => array_keys($vars)]);
|
|
$ctx = stream_context_create([
|
|
'http' => [
|
|
'method' => 'POST',
|
|
'header' => "Content-Type: application/json\r\n",
|
|
'timeout' => 15,
|
|
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
],
|
|
]);
|
|
$resp = @file_get_contents($apiBase, false, $ctx);
|
|
if ($resp !== false) {
|
|
$status = null;
|
|
if (isset($http_response_header) && is_array($http_response_header)) {
|
|
foreach ($http_response_header as $hdr) {
|
|
if (preg_match('~^HTTP/\\S+\\s+(\\d+)~i', $hdr, $m)) {
|
|
$status = (int)$m[1];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
$this->log('template_api_response_raw', [
|
|
'status' => $status,
|
|
'body' => $resp,
|
|
]);
|
|
$decoded = json_decode($resp, true);
|
|
if (is_array($decoded) && !empty($decoded['ok']) && !empty($decoded['html'])) {
|
|
$this->log('template_api_success', ['template' => $id, 'subject' => $decoded['subject'] ?? null, 'html_len' => strlen((string)$decoded['html'])]);
|
|
return [
|
|
'id' => $id,
|
|
'subject' => $decoded['subject'] ?? 'Papa-Kind-Treff',
|
|
'html' => $decoded['html'],
|
|
];
|
|
}
|
|
$this->log('template_api_response_invalid', ['template' => $id, 'response' => $decoded]);
|
|
} else {
|
|
$this->log('template_api_unreachable', ['template' => $id]);
|
|
}
|
|
}
|
|
|
|
// Fallback: einfacher Text
|
|
$subject = 'Papa-Kind-Treff';
|
|
$body = $id;
|
|
foreach ($vars as $k => $v) {
|
|
$body = str_replace(['{' . $k . '}', '{{' . $k . '}}'], (string)$v, $body);
|
|
}
|
|
$this->log('template_fallback_used', ['template' => $id]);
|
|
return [
|
|
'id' => $id,
|
|
'subject' => $subject,
|
|
'html' => nl2br(htmlspecialchars($body, ENT_QUOTES)),
|
|
];
|
|
}
|
|
|
|
public function sendTemplate(string $templateKey, string $to, array $vars = []): void
|
|
{
|
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
|
throw new \InvalidArgumentException('Invalid recipient email.');
|
|
}
|
|
|
|
$tpl = $this->renderTemplate($templateKey, $vars);
|
|
$resolvedId = $tpl['id'] ?? $templateKey;
|
|
$subject = $tpl['subject'] ?? 'Papa-Kind-Treff';
|
|
$html = $tpl['html'] ?? '';
|
|
|
|
$this->log('mail_rendered_template', [
|
|
'template_key' => $templateKey,
|
|
'template_id' => $resolvedId,
|
|
'subject' => $subject,
|
|
'html_len' => strlen((string)$html),
|
|
'html_preview' => substr((string)$html, 0, 200),
|
|
]);
|
|
|
|
$transport = getenv('MAIL_TRANSPORT') ?: 'mail';
|
|
$fromEmail = getenv('MAIL_FROM') ?: 'no-reply@' . $this->app->config()->primaryDomain;
|
|
$fromName = getenv('MAIL_FROM_NAME') ?: 'Papa-Kind-Treff';
|
|
|
|
$this->log('mail_send_start', [
|
|
'template_key' => $templateKey,
|
|
'template_id' => $resolvedId,
|
|
'to' => $to,
|
|
'transport' => $transport,
|
|
'subject' => $subject
|
|
]);
|
|
if ($transport === 'smtp') {
|
|
$this->sendSmtp($to, $subject, $html, $fromEmail, $fromName);
|
|
} else {
|
|
$this->sendMailFn($to, $subject, $html, $fromEmail, $fromName);
|
|
}
|
|
}
|
|
|
|
private function sendMailFn(string $to, string $subject, string $html, string $from, string $fromName): void
|
|
{
|
|
$headers = [];
|
|
if ($from) {
|
|
$headers[] = 'From: ' . sprintf('"%s" <%s>', addslashes($fromName), $from);
|
|
}
|
|
$headers[] = 'Content-Type: text/html; charset=utf-8';
|
|
$ok = @mail($to, $subject, $html, implode("\r\n", $headers));
|
|
$this->log('mail_mail_transport', ['to' => $to, 'ok' => $ok]);
|
|
if (!$ok) {
|
|
throw new \RuntimeException('mail() transport failed');
|
|
}
|
|
}
|
|
|
|
private function sendSmtp(string $to, string $subject, string $html, string $from, string $fromName): void
|
|
{
|
|
$host = getenv('SMTP_HOST') ?: '';
|
|
$port = (int)(getenv('SMTP_PORT') ?: 587);
|
|
$user = getenv('SMTP_USER') ?: '';
|
|
$pass = getenv('SMTP_PASS') ?: '';
|
|
$secure = strtolower(getenv('SMTP_SECURE') ?: 'tls'); // tls|ssl|none
|
|
|
|
if (!$host) {
|
|
$this->log('mail_smtp_missing_host_fallback_mail', []);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
|
|
$proto = ($secure === 'ssl') ? 'ssl://' : '';
|
|
$fp = @stream_socket_client($proto . $host . ':' . $port, $errno, $errstr, 15, STREAM_CLIENT_CONNECT);
|
|
if (!$fp) {
|
|
$this->log('mail_smtp_connect_failed', ['host' => $host, 'port' => $port, 'error' => $errstr]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
stream_set_timeout($fp, 15);
|
|
|
|
$transcript = [];
|
|
$readResponse = function (array $expectCodes = [], string $label = 'read') use ($fp, &$transcript): array {
|
|
$lines = [];
|
|
while (($line = fgets($fp, 515)) !== false) {
|
|
$line = rtrim($line, "\r\n");
|
|
$lines[] = $line;
|
|
$transcript[] = $label . ': ' . $line;
|
|
// SMTP multiline: code + '-' means more lines, code + ' ' means end
|
|
if (strlen($line) >= 4 && $line[3] === ' ') {
|
|
break;
|
|
}
|
|
}
|
|
$code = 0;
|
|
if ($lines) {
|
|
$code = (int)substr($lines[0], 0, 3);
|
|
}
|
|
return [
|
|
'ok' => !$expectCodes || in_array($code, $expectCodes, true),
|
|
'code' => $code,
|
|
'lines' => $lines,
|
|
];
|
|
};
|
|
$write = function (string $cmd, string $label = 'write', bool $mask = false) use ($fp, &$transcript): void {
|
|
$transcript[] = $label . ': ' . ($mask ? '[omitted]' : $cmd);
|
|
fwrite($fp, $cmd . "\r\n");
|
|
};
|
|
|
|
$resp = $readResponse([220], 'greeting');
|
|
if (!$resp['ok']) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_greeting_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
|
|
$write('EHLO ' . $this->app->config()->primaryDomain);
|
|
$resp = $readResponse([250], 'ehlo');
|
|
if (!$resp['ok']) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_ehlo_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
|
|
if ($secure === 'tls') {
|
|
$write('STARTTLS');
|
|
$resp = $readResponse([220], 'starttls');
|
|
if (!$resp['ok'] || !stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_starttls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
$write('EHLO ' . $this->app->config()->primaryDomain);
|
|
$resp = $readResponse([250], 'ehlo-tls');
|
|
if (!$resp['ok']) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_ehlo_tls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if ($user !== '') {
|
|
$write('AUTH LOGIN');
|
|
$resp = $readResponse([334], 'auth-login');
|
|
if (!$resp['ok']) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_auth_login_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
$write(base64_encode($user), 'auth-user', true);
|
|
$resp = $readResponse([334], 'auth-user');
|
|
if (!$resp['ok']) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_auth_user_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
$write(base64_encode($pass), 'auth-pass', true);
|
|
$resp = $readResponse([235], 'auth-pass');
|
|
if (!$resp['ok']) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_auth_pass_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
}
|
|
|
|
$write('MAIL FROM: <' . $from . '>');
|
|
$resp = $readResponse([250], 'mail-from');
|
|
if (!$resp['ok']) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_mailfrom_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
|
|
$write('RCPT TO: <' . $to . '>');
|
|
$resp = $readResponse([250, 251], 'rcpt-to');
|
|
if (!$resp['ok']) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_rcpt_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
|
|
$write('DATA');
|
|
$resp = $readResponse([354], 'data-start');
|
|
if (!$resp['ok']) {
|
|
fclose($fp);
|
|
$this->log('mail_smtp_data_start_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
|
|
$msg = "From: {$fromName} <{$from}>\r\n";
|
|
$msg .= "To: <{$to}>\r\n";
|
|
$msg .= "Subject: {$subject}\r\n";
|
|
$msg .= "MIME-Version: 1.0\r\n";
|
|
$msg .= "Content-Type: text/html; charset=utf-8\r\n\r\n";
|
|
$msg .= $html . "\r\n.\r\n";
|
|
$write($msg, 'data', false);
|
|
$resp = $readResponse([250], 'data-end');
|
|
|
|
$write('QUIT');
|
|
$readResponse([221], 'quit');
|
|
fclose($fp);
|
|
|
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
|
|
|
if (!$resp['ok']) {
|
|
$this->log('mail_smtp_send_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
|
return;
|
|
}
|
|
$this->log('mail_smtp_sent', ['to' => $to, 'host' => $host, 'port' => $port, 'secure' => $secure]);
|
|
}
|
|
}
|