0 ? ($lastError = func_get_arg(0)) : $lastError;
}
function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null): bool
{
$cfg = [
'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1',
'from_email' => get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''),
'from_name' => get_setting('smtp_from_name', config()['mail']['from_name'] ?? ''),
];
// Adressen mit der reservierten Endung .invalid (z. B. von Discord- oder Telegram-Nutzern ohne E-Mail) sind nie zustellbar: nichts senden, kein Fehler
if (preg_match('/@[^@\\s]+\\.invalid$/i', trim($to))) return true;
$smtpHost = get_setting('smtp_host', '');
$fromHeader = $fromOverride ?: smtp_default_from();
if ($smtpHost === '' && !$cfg['enabled'] && !($fromOverride && email_address_smtp_for(get_db(), $fromHeader))) {
// Weder eigener SMTP-Server konfiguriert noch PHP mail() aktiviert.
// Wird still ignoriert, damit der Rest der Anwendung normal funktioniert.
return false;
}
// Eigener SMTP-Server der Absenderadresse (osTicket-Reiter "Abgehend") hat Vorrang vor dem globalen.
$own = $fromOverride ? email_address_smtp_for(get_db(), $fromHeader) : null;
if ($own !== null) {
$mailer = new SmtpMailer(
$own['smtp_host'],
(int) $own['smtp_port'],
$own['smtp_encryption'] ?: 'tls',
(string) $own['smtp_username'],
$own['smtp_password_enc'] ? decrypt_secret($own['smtp_password_enc']) : '',
);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) {
smtp_last_error($mailer->getLastError());
error_log('DeskHub SMTP-Versand (eigener Server) fehlgeschlagen: ' . $mailer->getLastError());
}
health_record_send_result($ok, $mailer->getLastError());
return $ok;
}
if ($smtpHost !== '') {
$mailer = new SmtpMailer(
$smtpHost,
(int) get_setting('smtp_port', '587'),
get_setting('smtp_encryption', 'tls'),
get_setting('smtp_username', ''),
decrypt_secret(get_setting('smtp_password', '')),
);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) {
smtp_last_error($mailer->getLastError());
error_log('DeskHub SMTP-Versand fehlgeschlagen: ' . $mailer->getLastError());
}
health_record_send_result($ok, $mailer->getLastError());
return $ok;
}
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
$headers .= "From: {$fromHeader}\r\n";
return @mail($to, $subject, $htmlBody, $headers);
}
/**
* Wie send_mail(), protokolliert einen Fehlschlag aber zusätzlich sichtbar im
* Admin-Protokoll (staff/audit_log.php). System-Mails scheitern sonst komplett
* unbemerkt – der Kunde bekommt keine Fehlermeldung und niemand wird informiert.
*/
function email_send_and_log(string $to, string $subject, string $htmlBody, ?string $from, string $context, string $ticketNumber = ''): bool
{
$ok = send_mail($to, $subject, $htmlBody, $from);
if (!$ok) {
$ref = $ticketNumber !== '' ? " (Ticket #{$ticketNumber})" : '';
// admin_audit_log.action ist VARCHAR(255) NOT NULL – bei MySQL im Strict-Modus wirft ein
// zu langer Wert (z.B. eine ausführliche SMTP-Fehlermeldung) sonst eine harte Exception.
$message = mb_substr("{$context}{$ref} an {$to} fehlgeschlagen: " . smtp_last_error(), 0, 255);
audit_log(null, $message);
}
return $ok;
}
/** Standard-Absenderadresse: eigene SMTP-Absenderangabe, sonst die Werte aus config.php */
function smtp_default_from(): string
{
$cfg = [
'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1',
'from_email' => get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''),
'from_name' => get_setting('smtp_from_name', config()['mail']['from_name'] ?? ''),
];
$name = get_setting('smtp_from_name') ?: $cfg['from_name'];
$email = get_setting('smtp_from_email') ?: $cfg['from_email'];
return sprintf('%s <%s>', $name, $email);
}
/**
* Ermittelt die passende Absenderadresse für eine Abteilung (optional nach
* Priorität verfeinert) aus der E-Mail-Adressen-Verwaltung. Gibt null zurück,
* wenn keine passt – dann verwendet send_mail() die globale Standardadresse.
*/
function resolve_from_address(?int $departmentId, ?int $priorityId = null): ?string
{
if (!$departmentId) return null;
$pdo = get_db();
if ($priorityId) {
$stmt = $pdo->prepare('SELECT email, label FROM email_addresses WHERE department_id = ? AND priority_id = ? AND is_active = 1 LIMIT 1');
$stmt->execute([$departmentId, $priorityId]);
if ($row = $stmt->fetch()) {
return $row['label'] ? "{$row['label']} <{$row['email']}>" : $row['email'];
}
}
$stmt = $pdo->prepare('SELECT email, label FROM email_addresses WHERE department_id = ? AND is_default = 1 AND is_active = 1 LIMIT 1');
$stmt->execute([$departmentId]);
if ($row = $stmt->fetch()) {
return $row['label'] ? "{$row['label']} <{$row['email']}>" : $row['email'];
}
$stmt = $pdo->prepare('SELECT email, label FROM email_addresses WHERE department_id = ? AND is_active = 1 ORDER BY id LIMIT 1');
$stmt->execute([$departmentId]);
if ($row = $stmt->fetch()) {
return $row['label'] ? "{$row['label']} <{$row['email']}>" : $row['email'];
}
return null;
}
/**
* Liest eine anpassbare E-Mail-Vorlage aus den Einstellungen (Adminportal → E-Mail-Vorlagen)
* und ersetzt Platzhalter wie {ticket_number}. Fällt auf die mitgegebenen Standardtexte
* zurück, solange der Admin nichts Eigenes hinterlegt hat.
*
* @param array $vars Platzhalter ohne geschweifte Klammern, z.B. ['ticket_number' => '...']
* @return array{0:string,1:string} [Betreff, HTML-Text]
*/
function render_email_template(string $key, array $vars, string $defaultSubject, string $defaultBody): array
{
$subject = get_setting("email_template_{$key}_subject") ?: $defaultSubject;
$body = get_setting("email_template_{$key}_body") ?: $defaultBody;
$search = [];
$replace = [];
foreach ($vars as $name => $value) {
$search[] = '{' . $name . '}';
$replace[] = $value;
}
return [str_replace($search, $replace, $subject), str_replace($search, $replace, $body)];
}
// ====================================================================
// Einheitliches, modernes E-Mail-Design
// ====================================================================
// email_wrap() umschließt JEDEN Vorlagentext (Standard oder vom Admin
// angepasst) mit demselben Branding-Rahmen, damit alle System-Mails
// gleich aussehen. email_badge()/email_button() sind wiederverwendbare
// Bausteine für die Standardtexte der einzelnen Vorlagen.
/** Branding-Rahmen (Header, weiße Karte, Footer) um einen HTML-Textkörper. */
function email_wrap(string $innerHtml): string
{
$siteName = e(get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support'));
return <<
|
{$siteName}
|
|
{$innerHtml}
|
|
Diese E-Mail wurde automatisch von {$siteName} versendet.
|
HTML;
}
/** Farbige Hervorhebungs-Box, z.B. für die Ticketnummer. $label/$value dürfen bereits ersetzte {platzhalter} enthalten. */
function email_badge(string $label, string $value, string $color = '#0f6f6a', string $bg = '#e6f4f3'): string
{
return <<
|
{$label}
{$value}
|
HTML;
}
/** Button-artiger Call-to-Action-Link. */
function email_button(string $label, string $url, string $color = '#0f6f6a'): string
{
return <<
|
{$label}
|
HTML;
}
/** Wiederverwendeter Inline-Style für normale Absätze in Mail-Vorlagen. */
function email_p(string $html, string $marginBottom = '16px'): string
{
return "{$html}
";
}
/**
* Standard-Textkörper der vier anpassbaren Vorlagen (Adminportal → E-Mail-Vorlagen).
* Enthalten nur den Innenbereich – der Branding-Rahmen kommt beim Versand
* automatisch über email_wrap() dazu, auch wenn ein Admin den Text anpasst.
*/
function email_ticket_created_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihre Anfrage. Wir haben Ihr Ticket erhalten und kümmern uns so schnell wie möglich darum.', '20px')
. email_badge('Ihre Ticketnummer', '#{ticket_number}')
. email_p('Betreff: {subject}', '24px')
. email_button('Ticket ansehen', '{ticket_url}')
. 'Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter {lookup_url} prüfen.
';
}
function email_new_reply_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('es gibt eine neue Antwort zu Ihrem Ticket #{ticket_number}:')
. '{message}
'
. email_button('Ticket ansehen', '{ticket_url}');
}
function email_new_ticket_department_default_body(): string
{
return email_p('Ein neues Ticket wurde erstellt.')
. email_badge('Ticket', '#{ticket_number}')
. email_p('Von: {customer_name} ({customer_email})', '6px')
. email_p('Abteilung: {department_name}', '6px')
. email_p('Betreff: {subject}', '24px')
. email_button('Ticket ansehen', '{ticket_url}');
}
function email_ticket_overdue_default_body(): string
{
return email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:')
. email_badge('Ticket', '#{ticket_number}', '#b91c1c', '#fee2e2')
. email_p('Betreff: {subject}', '6px')
. email_p('Abteilung: {department_name}', '24px')
. email_button('Ticket ansehen', '{ticket_url}', '#dc2626');
}
function email_live_chat_transcript_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Unterlage:', '20px')
. '{transcript}'
. email_p('Bei weiteren Fragen antworten Sie einfach direkt auf diese E-Mail.', '20px')
. email_button('Ticket ansehen', '{ticket_url}');
}
/** Fertige, versandbereite HTML-Mail für Passwort-zurücksetzen-Links (Kunden- und Mitarbeiterbereich). */
function email_password_reset_body(string $link): string
{
$inner = email_p('Klicken Sie auf den folgenden Button, um Ihr Passwort zurückzusetzen. Der Link ist 1 Stunde lang gültig.')
. email_button('Passwort zurücksetzen', e($link))
. 'Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.
';
return email_wrap($inner);
}
/** @param string[] $collaboratorEmails Zusätzliche Empfänger (Kollaboratoren), die dieselbe Antwort-Mail erhalten */
function notify_customer_new_reply(array $ticket, string $message, string $signature = '', array $collaboratorEmails = []): void
{
// $message ist bereits über sanitize_staff_html() bereinigtes, sicheres HTML – nicht erneut escapen.
// $signature stammt aus der Mitarbeiter- oder Abteilungspflege und wurde dort ebenfalls bereinigt.
$messageWithSignature = $message . ($signature !== '' ? "{$signature}
" : '');
[$subject, $body] = render_email_template('new_reply', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'message' => $messageWithSignature,
'ticket_url' => e(ticket_access_url($ticket)),
], "[#{$ticket['ticket_number']}] {$ticket['subject']}", email_new_reply_default_body());
$body = email_wrap($body);
$from = resolve_from_address($ticket['department_id'] ?? null, $ticket['priority_id'] ?? null);
email_send_and_log($ticket['requester_email'], $subject, $body, $from, "Neue-Antwort-Mail", $ticket['ticket_number']);
foreach ($collaboratorEmails as $ccEmail) {
if ($ccEmail === '' || strcasecmp($ccEmail, $ticket['requester_email']) === 0) continue;
email_send_and_log($ccEmail, $subject, $body, $from, "Neue-Antwort-Mail (Kollaborateur)", $ticket['ticket_number']);
}
}
/** Standardtext der Erinnerung an Kunden (siehe cron/remind_customers.php). */
function email_customer_reminder_default_body(): string
{
return 'Brauchen Sie noch Hilfe?
'
. 'Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket #{ticket_number} („{subject}“) geantwortet und noch nichts von Ihnen gehört. Hat sich Ihr Anliegen erledigt, brauchen Sie nichts zu tun. Haben Sie noch Fragen, antworten Sie einfach.
'
. '{close_note}'
. 'Ticket ansehen
';
}
function notify_customer_ticket_created(array $ticket): void
{
[$subject, $body] = render_email_template('ticket_created', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => e(ticket_access_url($ticket)),
'lookup_url' => base_url() . '/ticket_lookup.php',
], "Ihr Ticket #{ticket_number} wurde erstellt", email_ticket_created_default_body());
$from = resolve_from_address($ticket['department_id'] ?? null, null);
email_send_and_log($ticket['requester_email'], $subject, email_wrap($body), $from, 'Ticket-erstellt-Mail', $ticket['ticket_number']);
}
/** Rendert eine Chat-Nachricht als E-Mail-taugliche (Inline-Styles statt CSS-Klassen) Zeile. */
function email_chat_transcript_row(string $authorLabel, string $message, string $time, bool $isStaff): string
{
$align = $isStaff ? 'right' : 'left';
$bg = $isStaff ? '#0f6f6a' : '#f1f5f9';
$color = $isStaff ? '#ffffff' : '#16202a';
$safeMessage = nl2br(e($message));
return <<
|
{$authorLabel} · {$time}
|
HTML;
}
/**
* Schickt dem Kunden nach Ende eines Live-Chats (staff/live_chat.php, Aktion "end")
* den vollständigen Verlauf als E-Mail zu – abschaltbar über die Einstellung
* "live_chat_send_transcript" (Standard: an). $messages im Format wie von
* chat_messages() in live_chat.php: [['authorType'=>..,'authorName'=>..,'message'=>..,'createdAt'=>..], ...]
*/
function notify_customer_chat_transcript(array $ticket, array $messages): void
{
if (get_setting('live_chat_send_transcript', '1') !== '1') return;
if (!$messages) return;
$rows = '';
foreach ($messages as $m) {
$isStaff = $m['authorType'] === 'staff';
$label = $isStaff ? ($m['authorName'] ?: 'Support') : ($ticket['requester_name'] ?? 'Sie');
$time = substr((string) $m['createdAt'], 0, 16);
// Mitarbeiter-Nachrichten sind sanitiztes HTML (Rich-Text-Editor), Kunden-Nachrichten
// rohes Plain-Text (siehe live_chat.php-Konvention) – für die E-Mail beides einheitlich
// auf reinen Text reduzieren, sonst würden HTML-Tags als sichtbarer Text erscheinen.
$rawMessage = (string) $m['message'];
$plainMessage = preg_match('/<[a-zA-Z][^>]*>/i', $rawMessage)
? trim(html_entity_decode(strip_tags(str_replace(['
', '
', '
', '
'], "\n", $rawMessage)), ENT_QUOTES | ENT_HTML5, 'UTF-8'))
: $rawMessage;
$rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff);
}
$transcriptBlock = '' . $rows . '
';
[$subject, $body] = render_email_template('live_chat_transcript', [
'ticket_number' => $ticket['ticket_number'],
'customer_name' => e($ticket['requester_name'] ?? ''),
'transcript' => $transcriptBlock,
'ticket_url' => e(ticket_access_url($ticket)),
], 'Ihr Chatverlauf – Ticket #{ticket_number}', email_live_chat_transcript_default_body());
$from = resolve_from_address($ticket['department_id'] ?? null, null);
email_send_and_log($ticket['requester_email'], $subject, email_wrap($body), $from, 'Chatverlauf-Mail', $ticket['ticket_number']);
}
function notify_department_new_ticket(array $ticket, string $departmentEmail): void
{
[$subject, $body] = render_email_template('new_ticket_department', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'customer_email' => e($ticket['requester_email'] ?? ''),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "Neues Ticket [#{ticket_number}] {subject}", email_new_ticket_department_default_body());
email_send_and_log($departmentEmail, $subject, email_wrap($body), null, 'Neues-Ticket-Mail (Abteilung)', $ticket['ticket_number']);
}
/**
* Benachrichtigt per E-Mail: alle aktiven Admins (immer) sowie Mitarbeiter, deren
* Hauptabteilung der Ticket-Abteilung entspricht oder die für diese Abteilung als
* zusätzliche Abteilung mit aktivierter Benachrichtigung (staff_department.notify) eingetragen sind.
* Ergänzt die bestehende generische Abteilungs-E-Mail (notify_department_new_ticket), die weiterhin
* zusätzlich an die in der E-Mail-Adressen-Verwaltung hinterlegte Postfach-Adresse geht.
*/
function notify_staff_new_ticket(array $ticket): void
{
$pdo = get_db();
$deptId = $ticket['department_id'] ?? null;
if ($deptId) {
$stmt = $pdo->prepare(
"SELECT DISTINCT email FROM staff WHERE is_active = 1 AND (
role = 'admin'
OR department_id = ?
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1)
)"
);
$stmt->execute([$deptId, $deptId]);
} else {
$stmt = $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'");
}
$recipients = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!$recipients) return;
[$subject, $body] = render_email_template('new_ticket_department', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'customer_email' => e($ticket['requester_email'] ?? ''),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "Neues Ticket [#{ticket_number}] {subject}", email_new_ticket_department_default_body());
$body = email_wrap($body);
foreach ($recipients as $email) {
email_send_and_log($email, $subject, $body, null, 'Neues-Ticket-Mail (Mitarbeiter)', $ticket['ticket_number']);
}
}
/** Benachrichtigt alle aktiven Admins per E-Mail, wenn ein Ticket neu als überfällig erkannt wurde */
function notify_admins_overdue_ticket(array $ticket): void
{
$admins = get_db()->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
if (!$admins) return;
[$subject, $body] = render_email_template('ticket_overdue', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "SLA überschritten: Ticket #{ticket_number}", email_ticket_overdue_default_body());
$body = email_wrap($body);
foreach ($admins as $email) {
email_send_and_log($email, $subject, $body, null, 'Ticket-überfällig-Mail', $ticket['ticket_number']);
}
}