philippurbschat.de/home/app/services/MailService.php

333 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
class MailService {
private static function getBaseUrl(): string {
$base = Config::get('BASE_URL', 'https://philippurbschat.de/');
return rtrim($base, '/') . '/';
}
private static function getFromEmail(): string {
return Config::get('MAIL_FROM', 'hi@philippurbschat.de');
}
private static function getFromName(): string {
return Config::get('MAIL_FROM_NAME', 'Philipp Urbschat');
}
/**
* Send an invitation email with registration link
*/
public static function sendInvitation(string $toEmail, string $token, array $assignedProjects = [], ?string $note = null): bool {
$baseUrl = self::getBaseUrl();
$registerUrl = $baseUrl . 'register?token=' . urlencode($token);
$moduleNames = [];
foreach ($assignedProjects as $slug) {
$mod = ModuleService::getBySlug($slug);
if ($mod) {
$moduleNames[] = strtoupper($mod['name']);
}
}
$modulesText = !empty($moduleNames) ? implode(', ', $moduleNames) : 'Alle Basis-Module';
$subject = 'Persönliche Einladung zu philippurbschat.de';
$bodyHtml = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
</head>
<body style="margin: 0; padding: 0; background-color: #020617; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Helvetica, Arial, sans-serif;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" bgcolor="#020617" style="background-color: #020617; width: 100%; padding: 40px 15px;">
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 540px; background-color: #0b1120; border: 1px solid #1e293b; border-radius: 24px; padding: 36px 32px; box-shadow: 0 10px 30px rgba(0,0,0,0.6);" bgcolor="#0b1120">
<tr>
<td style="padding-bottom: 24px;">
<span style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 20px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">
<span style="color: #10b981;">_</span>PhilippUrbschat
</span>
</td>
</tr>
<tr>
<td>
<p style="font-size: 15px; line-height: 1.6; color: #ffffff; margin: 0 0 16px 0;">
Hallo,
</p>
<p style="font-size: 15px; line-height: 1.6; color: #e2e8f0; margin: 0 0 18px 0;">
du wurdest eingeladen, dir einen persönlichen Zugang auf <strong style="color: #ffffff;">philippurbschat.de</strong> anzulegen.
</p>
' . (!empty($note) ? '
<div style="background-color: #020617; border: 1px solid #1e293b; border-left: 3px solid #06b6d4; padding: 12px 16px; margin: 18px 0; border-radius: 8px; font-size: 13px; color: #cbd5e1; line-height: 1.5;">
' . htmlspecialchars($note) . '
</div>' : '') . '
<div style="background-color: #020617; border: 1px solid #1e293b; border-radius: 12px; padding: 16px 18px; margin: 22px 0;">
<div style="font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: #94a3b8; margin-bottom: 6px;">Freigeschaltete Module:</div>
<div style="font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; font-weight: bold; color: #34d399;">' . htmlspecialchars($modulesText) . '</div>
</div>
<table border="0" cellspacing="0" cellpadding="0" style="margin: 28px auto 24px auto;">
<tr>
<td align="center" style="background-color: #020617; border: 2px solid #10b981; border-radius: 12px;">
<a href="' . htmlspecialchars($registerUrl) . '" target="_blank" style="display: inline-block; padding: 14px 28px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; font-weight: 800; color: #34d399 !important; text-decoration: none !important; letter-spacing: 0.08em; text-transform: uppercase;">
<span style="color: #34d399; font-weight: 800;">Jetzt Account erstellen →</span>
</a>
</td>
</tr>
</table>
<p style="font-size: 12px; line-height: 1.6; color: #94a3b8; margin-top: 24px; word-break: break-all;">
Falls der Button nicht funktioniert, kopiere bitte diesen Link in deinen Browser:<br>
<a href="' . htmlspecialchars($registerUrl) . '" style="color: #38bdf8; text-decoration: underline; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px;">' . htmlspecialchars($registerUrl) . '</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>';
$bodyText = "Hallo,\n\n"
. "du wurdest eingeladen, dir einen persönlichen Zugang auf philippurbschat.de anzulegen.\n\n"
. (!empty($note) ? "Hinweis: {$note}\n\n" : "")
. "Freigeschaltete Systeme: {$modulesText}\n\n"
. "Öffne folgenden Link, um dein Konto zu erstellen:\n"
. $registerUrl . "\n\n"
. "Viele Grüße,\nPhilipp Urbschat";
return self::send($toEmail, $subject, $bodyHtml, $bodyText);
}
/**
* Send a password reset email
*/
public static function sendPasswordReset(string $toEmail, string $token): bool {
$baseUrl = self::getBaseUrl();
$resetUrl = $baseUrl . 'reset-password?token=' . urlencode($token);
$subject = 'Passwort zurücksetzen philippurbschat.de';
$bodyHtml = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
</head>
<body style="margin: 0; padding: 0; background-color: #020617; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Helvetica, Arial, sans-serif;">
<table border="0" cellpadding="0" cellspacing="0" width="100%" bgcolor="#020617" style="background-color: #020617; width: 100%; padding: 40px 15px;">
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 540px; background-color: #0b1120; border: 1px solid #1e293b; border-radius: 24px; padding: 36px 32px; box-shadow: 0 10px 30px rgba(0,0,0,0.6);" bgcolor="#0b1120">
<tr>
<td style="padding-bottom: 24px;">
<span style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 20px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">
<span style="color: #22d3ee;">_</span>Passwort-Reset
</span>
</td>
</tr>
<tr>
<td>
<p style="font-size: 15px; line-height: 1.6; color: #ffffff; margin: 0 0 16px 0;">
Hallo,
</p>
<p style="font-size: 15px; line-height: 1.6; color: #e2e8f0; margin: 0 0 18px 0;">
wir haben eine Anfrage zum Zurücksetzen deines Passworts auf <strong style="color: #ffffff;">philippurbschat.de</strong> erhalten.
</p>
<table border="0" cellspacing="0" cellpadding="0" style="margin: 28px auto 24px auto;">
<tr>
<td align="center" style="background-color: #020617; border: 2px solid #06b6d4; border-radius: 12px;">
<a href="' . htmlspecialchars($resetUrl) . '" target="_blank" style="display: inline-block; padding: 14px 28px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; font-weight: 800; color: #22d3ee !important; text-decoration: none !important; letter-spacing: 0.08em; text-transform: uppercase;">
<span style="color: #22d3ee; font-weight: 800;">Neues Passwort vergeben →</span>
</a>
</td>
</tr>
</table>
<div style="background-color: #020617; border: 1px solid #1e293b; border-left: 3px solid #06b6d4; border-radius: 8px; padding: 14px 16px; margin: 20px 0; font-size: 13px; color: #e2e8f0; line-height: 1.5;">
Dieser Link ist aus Sicherheitsgründen <strong style="color: #ffffff;">1 Stunde</strong> lang gültig.
</div>
<p style="font-size: 12px; line-height: 1.6; color: #94a3b8; margin-top: 24px; word-break: break-all;">
Falls der Button nicht funktioniert, nutze bitte diesen Link:<br>
<a href="' . htmlspecialchars($resetUrl) . '" style="color: #38bdf8; text-decoration: underline; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px;">' . htmlspecialchars($resetUrl) . '</a>
</p>
<p style="font-size: 11px; line-height: 1.6; color: #64748b; margin-top: 22px; border-top: 1px solid #1e293b; padding-top: 16px;">
Wenn du diesen Reset nicht angefordert hast, kannst du diese Nachricht einfach ignorieren. Dein bisheriges Passwort bleibt unverändert gültig.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>';
$bodyText = "Hallo,\n\n"
. "wir haben eine Anfrage zum Zurücksetzen deines Passworts auf philippurbschat.de erhalten.\n\n"
. "Klicke auf den folgenden Link, um ein neues Passwort festzulegen (1 Stunde gültig):\n"
. $resetUrl . "\n\n"
. "Falls du diesen Reset nicht angefordert hast, kannst du diese Nachricht ignorieren.\n\n"
. "Viele Grüße,\nPhilipp Urbschat";
return self::send($toEmail, $subject, $bodyHtml, $bodyText);
}
/**
* Internal mail dispatcher
*/
private static function send(string $to, string $subject, string $htmlBody, string $textBody): bool {
$fromEmail = self::getFromEmail();
$fromName = self::getFromName();
// 1. Always log locally for auditing & development
self::logEmail($to, $subject, $textBody);
// 2. Try SMTP if credentials exist
$smtpHost = Config::get('SMTP_HOST');
if (!empty($smtpHost)) {
try {
return self::sendViaSmtp($to, $subject, $htmlBody, $textBody);
} catch (Exception $e) {
error_log("MailService SMTP Error: " . $e->getMessage());
}
}
// 3. Fallback to PHP native mail()
$boundary = "==Multipart_Boundary_x" . md5(uniqid(time()));
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$encodedFromName = '=?UTF-8?B?' . base64_encode($fromName) . '?=';
$headers = [];
$headers[] = "MIME-Version: 1.0";
$headers[] = "From: {$encodedFromName} <{$fromEmail}>";
$headers[] = "Reply-To: {$fromEmail}";
$headers[] = "Content-Type: multipart/alternative; boundary=\"{$boundary}\"";
$headers[] = "X-Mailer: PhilippUrbschat-Mailer/1.0";
$message = "--{$boundary}\r\n";
$message .= "Content-Type: text/plain; charset=UTF-8\r\n";
$message .= "Content-Transfer-Encoding: base64\r\n\r\n";
$message .= chunk_split(base64_encode($textBody)) . "\r\n";
$message .= "--{$boundary}\r\n";
$message .= "Content-Type: text/html; charset=UTF-8\r\n";
$message .= "Content-Transfer-Encoding: base64\r\n\r\n";
$message .= chunk_split(base64_encode($htmlBody)) . "\r\n";
$message .= "--{$boundary}--\r\n";
$headerStr = implode("\r\n", $headers);
$additionalParams = "-f " . escapeshellarg($fromEmail);
$sent = @mail($to, $encodedSubject, $message, $headerStr, $additionalParams);
if (!$sent) {
error_log("MailService: mail() returned false. Email was logged to data/mail_log.txt");
}
return true;
}
private static function logEmail(string $to, string $subject, string $body): void {
$logDir = __DIR__ . '/../../data';
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$logFile = $logDir . '/mail_log.txt';
$timestamp = date('Y-m-d H:i:s');
$logEntry = str_repeat('=', 60) . "\n"
. "TIMESTAMP: {$timestamp}\n"
. "TO: {$to}\n"
. "SUBJECT: {$subject}\n"
. "BODY:\n{$body}\n\n";
@file_put_contents($logFile, $logEntry, FILE_APPEND);
}
private static function sendViaSmtp(string $to, string $subject, string $htmlBody, string $textBody): bool {
$host = Config::get('SMTP_HOST');
$port = (int)Config::get('SMTP_PORT', 587);
$user = Config::get('SMTP_USER');
$pass = Config::get('SMTP_PASS');
$from = self::getFromEmail();
$fromName = self::getFromName();
$socket = @fsockopen(($port === 465 ? 'ssl://' : '') . $host, $port, $errno, $errstr, 10);
if (!$socket) {
throw new Exception("Socket connection failed: {$errstr} ({$errno})");
}
$getResponse = function() use ($socket) {
$response = '';
while ($str = fgets($socket, 515)) {
$response .= $str;
if (substr($str, 3, 1) === ' ') break;
}
return $response;
};
$sendCommand = function($cmd) use ($socket, $getResponse) {
fputs($socket, $cmd . "\r\n");
return $getResponse();
};
$getResponse(); // Read greeting
$sendCommand("EHLO " . gethostname());
if ($port === 587) {
$tlsRes = $sendCommand("STARTTLS");
if (strpos($tlsRes, '220') === 0) {
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
$sendCommand("EHLO " . gethostname());
}
}
if (!empty($user) && !empty($pass)) {
$sendCommand("AUTH LOGIN");
$sendCommand(base64_encode($user));
$sendCommand(base64_encode($pass));
}
$sendCommand("MAIL FROM: <{$from}>");
$sendCommand("RCPT TO: <{$to}>");
$sendCommand("DATA");
$boundary = "==Multipart_Boundary_x" . md5(uniqid(time()));
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$encodedFromName = '=?UTF-8?B?' . base64_encode($fromName) . '?=';
$headers = [
"MIME-Version: 1.0",
"From: {$encodedFromName} <{$from}>",
"To: {$to}",
"Subject: {$encodedSubject}",
"Content-Type: multipart/alternative; boundary=\"{$boundary}\"",
"X-Mailer: PhilippUrbschat-Mailer/1.0"
];
$content = implode("\r\n", $headers) . "\r\n\r\n";
$content .= "--{$boundary}\r\n";
$content .= "Content-Type: text/plain; charset=UTF-8\r\n";
$content .= "Content-Transfer-Encoding: base64\r\n\r\n";
$content .= chunk_split(base64_encode($textBody)) . "\r\n";
$content .= "--{$boundary}\r\n";
$content .= "Content-Type: text/html; charset=UTF-8\r\n";
$content .= "Content-Transfer-Encoding: base64\r\n\r\n";
$content .= chunk_split(base64_encode($htmlBody)) . "\r\n";
$content .= "--{$boundary}--\r\n.";
$sendCommand($content);
$sendCommand("QUIT");
fclose($socket);
return true;
}
}