0 ? ($lastError = func_get_arg(0)) : $lastError; } /** * Sendet eine Mail. Scheitert der Versand, obwohl ein Versandweg eingerichtet ist, kommt die Mail in die Warteschlange * (includes/mail_queue.php) und wird später erneut versucht; der Rückgabewert bleibt dann false, mail_last_queued() ist true. * $queue = false für Test-Mails und für die Wiederholungsläufe der Warteschlange selbst. */ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null, bool $queue = true): bool { mail_attempted(false); mail_last_queued(false); $ok = send_mail_now($to, $subject, $htmlBody, $fromOverride); if (!$ok && $queue && mail_attempted() && mail_queue_enabled()) { try { if (mail_queue_add(get_db(), $to, $subject, $htmlBody, $fromOverride, smtp_last_error()) !== null) mail_last_queued(true); } catch (Throwable $e) { error_log('send_mail queue: ' . $e->getMessage()); } } return $ok; } function send_mail_now(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']) : '', ); mail_attempted(true); $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', '')), ); mail_attempted(true); $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 .= "X-DeskHub-Mail: 1\r\n"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket $headers .= "Content-type: text/html; charset=UTF-8\r\n"; $headers .= "From: {$fromHeader}\r\n"; $mid = mail_ticket_message_id($subject, preg_match('/@([^>\s]+)>?\s*$/', $fromHeader, $hm) ? $hm[1] : ($_SERVER['SERVER_NAME'] ?? 'localhost')); if ($mid !== null) $headers .= "Message-ID: {$mid}\r\n"; mail_attempted(true); 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 Rahmen, damit alle System-Mails gleich aussehen. // email_badge()/email_button()/email_p() sind wiederverwendbare Bausteine. // Alles ist tabellenbasiert mit Inline-Styles und für Outlook (Word-Engine), // Gmail, Apple Mail und Handys gebaut: feste Breite 600 px, die auf kleinen // Bildschirmen schrumpft; der Knopf ist in Outlook ein echter Knopf (VML). const EMAIL_FONT = "'Segoe UI',-apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif"; /** Rahmen (Kopf mit Name oder Logo, weiße Karte, Fußzeile) um einen HTML-Textkörper. */ function email_wrap(string $innerHtml): string { $siteNameRaw = (string) get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support'); $siteName = e($siteNameRaw); $font = EMAIL_FONT; $logo = (string) get_setting('logo_path', ''); $brand = '' . $siteName . ''; if ($logo !== '' && (str_starts_with($logo, '/') || preg_match('#^https://#i', $logo))) { $src = e(str_starts_with($logo, '/') ? base_url() . $logo : $logo); $img = '' . $siteName . ''; $brand = get_setting('logo_hide_name', '0') === '1' ? $img : $img . '' . $siteName . ''; } $home = e(base_url()); return <<
{$brand}
 
{$innerHtml}
Diese E-Mail wurde automatisch von {$siteName} versendet. Bitte antworten Sie nur, wenn es um Ihr Ticket geht.
HTML; } /** Hervorgehobene Karte, z.B. für die Ticketnummer (volle Breite, farbiger Balken links). $label/$value dürfen {platzhalter} enthalten. */ function email_badge(string $label, string $value, string $color = '#0f6f6a', string $bg = '#e6f4f3'): string { $font = EMAIL_FONT; return <<  
{$label}
{$value}
HTML; } /** Knopf mit Link: in Outlook ein echter Knopf (VML), sonst ein abgerundeter Link. */ function email_button(string $label, string $url, string $color = '#0f6f6a'): string { $font = EMAIL_FONT; $plain = trim(strip_tags($label)); $w = max(180, (int) round(mb_strlen($plain) * 9.2 + 64)); return << {$label} HTML; } /** Normaler Absatz. */ function email_p(string $html, string $marginBottom = '16px'): string { return "

{$html}

"; } /** Zitat-/Textkasten für die eigentliche Nachricht (zum Beispiel die Antwort des Supports). */ function email_quote(string $html): string { return '' . '' . '' . '
 ' . $html . '
'; } /** Überschrift im Mailtext. */ function email_h(string $text): string { return '

' . $text . '

'; } /** Kleingedruckter Hinweis unter dem Knopf. */ function email_small(string $html): 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_h('Wir haben Ihre Anfrage erhalten') . 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}') . email_small('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_h('Neue Antwort zu Ihrem Ticket') . email_p('Hallo {customer_name},') . email_p('es gibt eine neue Antwort zu Ihrem Ticket #{ticket_number}:', '18px') . email_quote('{message}') . email_button('Ticket ansehen', '{ticket_url}'); } function email_new_ticket_department_default_body(): string { return email_h('Neues Ticket') . email_p('Ein neues Ticket wurde erstellt.', '18px') . 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_h('SLA-Frist überschritten') . email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:', '18px') . 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_h('Ihr Chat-Verlauf') . email_p('Hallo {customer_name},') . email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Anfrage:', '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_h('Passwort zurücksetzen') . email_p('Klicken Sie auf den folgenden Knopf, um ein neues Passwort festzulegen. Der Link ist 1 Stunde lang gültig.', '22px') . email_button('Passwort zurücksetzen', e($link)) . email_small('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 email_h('Brauchen Sie noch Hilfe?') . email_p('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.', '16px') . '{close_note}' . '
 
' . email_button('Ticket ansehen', '{ticket_url}'); } 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}
{$safeMessage}
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 = message_is_html($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 id 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 id FROM staff WHERE is_active = 1 AND role = 'admin'"); } // Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Mail $recipients = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $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 { $pdo = get_db(); $admins = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id 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']); } } // ── SLA-Überschreitung: nur an Betroffene ───────────────────────────────────── /** * Wer bei einer SLA-Überschreitung eine Mail bekommt: der Bearbeiter des Tickets (ist er im Urlaub, seine Vertretung), sonst die * Mitarbeiter der Abteilung (Hauptabteilung oder zusätzliche Abteilung mit Benachrichtigung), ohne zugewiesenes Ticket und ohne Abteilungs- * Mitarbeiter die aktiven Administratoren. Nie die Postfach-Adresse der Abteilung: Sie wird vom E-Mail-Abruf gelesen und würde Tickets erzeugen. * @return list E-Mail-Adressen */ /** Wer bei SLA-Überschreitung benachrichtigt wird: „admins“ (Standard: nur die Administratoren, nur per E-Mail) oder „assignee“ (Bearbeiter bzw. Abteilung, Mail und Push). */ function sla_notify_mode(): string { return get_setting('sla_notify_mode', 'admins') === 'assignee' ? 'assignee' : 'admins'; } function sla_overdue_recipients(PDO $pdo, array $ticket): array { $emails = []; $assignee = (int) ($ticket['assigned_staff_id'] ?? 0); if (sla_notify_mode() === 'admins') { $assignee = 0; // nur Administratoren: weder der Bearbeiter noch die Abteilung $ticket['department_id'] = null; } if ($assignee > 0) { // Im Urlaub: an die Vertretung; ohne nutzbare Vertretung gar nicht an den Abwesenden (dann gehen die Abteilung bzw. die Administratoren vor) $target = staff_is_on_vacation($assignee) ? vacation_target($pdo, $assignee) : $assignee; if ($target !== null) { $q = $pdo->prepare('SELECT email FROM staff WHERE id = ? AND is_active = 1 AND is_on_vacation = 0'); $q->execute([$target]); $e = $q->fetchColumn(); if ($e) $emails[] = (string) $e; } } if (!$emails && !empty($ticket['department_id'])) { $q = $pdo->prepare( "SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (department_id = ? OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1))" ); $q->execute([(int) $ticket['department_id'], (int) $ticket['department_id']]); $emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $q->fetchAll(PDO::FETCH_COLUMN))); } if (!$emails) { $emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN))); } $own = mail_own_system_addresses($pdo); $out = []; foreach ($emails as $e) { $e = trim($e); if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL) && !in_array(strtolower($e), $own, true)) $out[strtolower($e)] = $e; } return array_values($out); } /** Sendet die SLA-Mail (Vorlage „ticket_overdue“) an die Betroffenen. Liefert die Zahl der Empfänger. */ function notify_overdue_ticket_staff(array $ticket): int { $pdo = get_db(); $recipients = sla_overdue_recipients($pdo, $ticket); if (!$recipients) return 0; [$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 ($recipients as $email) email_send_and_log($email, $subject, $body, null, 'SLA-überschritten-Mail', $ticket['ticket_number']); return count($recipients); } // ── Schutz vor Mail-Schleifen ──────────────────────────────────────────────── /** Adressen (klein geschrieben), von denen DeskHub selbst Mails verschickt: Standard-Absender und Absender-/Abteilungsadressen. */ function mail_own_system_addresses(PDO $pdo): array { $out = []; $add = function ($a) use (&$out) { $a = strtolower(trim((string) $a)); if ($a !== '' && str_contains($a, '@')) $out[$a] = true; }; $add(get_setting('smtp_from_email', config()['mail']['from_email'] ?? '')); $add(config()['mail']['from_email'] ?? ''); try { foreach ($pdo->query('SELECT email FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a); foreach ($pdo->query('SELECT email FROM departments')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a); } catch (Throwable $e) { } return array_keys($out); } /** * Ist das eine Mail, die DeskHub selbst verschickt hat (und die über ein abgerufenes Postfach zurückkommt)? Daraus darf kein Ticket werden. * Erkennung: unsere Kopfzeile X-DeskHub-Mail oder ein Absender, der eine unserer eigenen Systemadressen ist. */ function mail_is_own_system_mail(PDO $pdo, string $fromEmail, string $rawHeader = ''): bool { if ($rawHeader !== '' && preg_match('/^X-DeskHub-Mail:/mi', $rawHeader)) return true; return in_array(strtolower(trim($fromEmail)), mail_own_system_addresses($pdo), true); }