664 lines
34 KiB
PHP
664 lines
34 KiB
PHP
<?php
|
||
/**
|
||
* Mailer mit zwei Versandwegen:
|
||
* 1) Eigener SMTP-Client (includes/smtp_mailer.php), sobald unter Einstellungen
|
||
* → E-Mail-Versand ein SMTP-Server hinterlegt ist – funktioniert unabhängig
|
||
* von der Hosting-Konfiguration und ohne externe Bibliothek.
|
||
* 2) Fallback auf die einfache PHP-Funktion mail() (config.php → mail.enabled),
|
||
* falls kein SMTP-Server hinterlegt ist.
|
||
* Der Rest der Anwendung ruft ausschließlich send_mail() auf.
|
||
*/
|
||
|
||
function smtp_last_error(): string
|
||
{
|
||
static $lastError = '';
|
||
return func_num_args() > 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<string,string> $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 = '<span style="font-family:' . $font . ';color:#ffffff;font-size:20px;font-weight:700;letter-spacing:-.01em;">' . $siteName . '</span>';
|
||
if ($logo !== '' && (str_starts_with($logo, '/') || preg_match('#^https://#i', $logo))) {
|
||
$src = e(str_starts_with($logo, '/') ? base_url() . $logo : $logo);
|
||
$img = '<img src="' . $src . '" alt="' . $siteName . '" height="34" style="display:inline-block;height:34px;width:auto;border:0;vertical-align:middle;">';
|
||
$brand = get_setting('logo_hide_name', '0') === '1' ? $img : $img . '<span style="font-family:' . $font . ';color:#ffffff;font-size:20px;font-weight:700;vertical-align:middle;padding-left:12px;">' . $siteName . '</span>';
|
||
}
|
||
$home = e(base_url());
|
||
return <<<HTML
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#eef2f6" style="background:#eef2f6;">
|
||
<tr>
|
||
<td align="center" style="padding:28px 12px;">
|
||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;">
|
||
<tr>
|
||
<td bgcolor="#0f6f6a" style="background:#0f6f6a;border-radius:14px 14px 0 0;padding:24px 36px;">
|
||
{$brand}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td height="4" bgcolor="#14958d" style="background:#14958d;font-size:0;line-height:0;height:4px;"> </td>
|
||
</tr>
|
||
<tr>
|
||
<td bgcolor="#ffffff" style="background:#ffffff;padding:36px 36px 34px;font-family:{$font};color:#1f2937;font-size:15px;line-height:1.6;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;">
|
||
{$innerHtml}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td bgcolor="#ffffff" style="background:#ffffff;border:1px solid #e2e8f0;border-top:1px solid #edf1f5;border-radius:0 0 14px 14px;padding:18px 36px;font-family:{$font};">
|
||
<span style="color:#94a3b8;font-size:12px;line-height:1.5;">Diese E-Mail wurde automatisch von <a href="{$home}" style="color:#64748b;text-decoration:none;">{$siteName}</a> versendet. Bitte antworten Sie nur, wenn es um Ihr Ticket geht.</span>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
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 <<<HTML
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 22px;">
|
||
<tr>
|
||
<td width="5" bgcolor="{$color}" style="background:{$color};width:5px;font-size:0;line-height:0;"> </td>
|
||
<td bgcolor="{$bg}" style="background:{$bg};padding:16px 20px;font-family:{$font};">
|
||
<div style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:{$color};line-height:1.4;">{$label}</div>
|
||
<div style="font-size:24px;font-weight:700;color:{$color};line-height:1.3;font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
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 <<<HTML
|
||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:6px 0 4px;">
|
||
<tr>
|
||
<td align="center" bgcolor="{$color}" style="background:{$color};border-radius:10px;">
|
||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{$url}" style="height:48px;v-text-anchor:middle;width:{$w}px;" arcsize="20%" stroke="f" fillcolor="{$color}"><w:anchorlock/><center style="color:#ffffff;font-family:'Segoe UI',Arial,sans-serif;font-size:15px;font-weight:bold;">{$label}</center></v:roundrect><![endif]-->
|
||
<!--[if !mso]><!-- --><a href="{$url}" style="display:inline-block;padding:14px 30px;font-family:{$font};color:#ffffff;font-size:15px;font-weight:600;line-height:1.2;text-decoration:none;border-radius:10px;background:{$color};">{$label}</a><!--<![endif]-->
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
HTML;
|
||
}
|
||
|
||
/** Normaler Absatz. */
|
||
function email_p(string $html, string $marginBottom = '16px'): string
|
||
{
|
||
return "<p style=\"margin:0 0 {$marginBottom};color:#1f2937;font-size:15px;line-height:1.6;font-family:" . EMAIL_FONT . ";\">{$html}</p>";
|
||
}
|
||
|
||
/** Zitat-/Textkasten für die eigentliche Nachricht (zum Beispiel die Antwort des Supports). */
|
||
function email_quote(string $html): string
|
||
{
|
||
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px;"><tr>'
|
||
. '<td width="4" bgcolor="#0f6f6a" style="background:#0f6f6a;width:4px;font-size:0;line-height:0;"> </td>'
|
||
. '<td bgcolor="#f6f9fb" style="background:#f6f9fb;padding:16px 20px;color:#1f2937;font-size:15px;line-height:1.65;font-family:' . EMAIL_FONT . ';">' . $html . '</td>'
|
||
. '</tr></table>';
|
||
}
|
||
|
||
/** Überschrift im Mailtext. */
|
||
function email_h(string $text): string
|
||
{
|
||
return '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:' . EMAIL_FONT . ';">' . $text . '</h2>';
|
||
}
|
||
|
||
/** Kleingedruckter Hinweis unter dem Knopf. */
|
||
function email_small(string $html): string
|
||
{
|
||
return '<p style="margin:22px 0 0;color:#64748b;font-size:13px;line-height:1.55;font-family:' . EMAIL_FONT . ';">' . $html . '</p>';
|
||
}
|
||
|
||
/**
|
||
* 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('<strong>Betreff:</strong> {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 <a href="{lookup_url}" style="color:#0f6f6a;">{lookup_url}</a> 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 <strong>#{ticket_number}</strong>:', '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('<strong>Von:</strong> {customer_name} ({customer_email})', '6px')
|
||
. email_p('<strong>Abteilung:</strong> {department_name}', '6px')
|
||
. email_p('<strong>Betreff:</strong> {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('<strong>Betreff:</strong> {subject}', '6px')
|
||
. email_p('<strong>Abteilung:</strong> {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 <strong>1 Stunde</strong> 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 !== '' ? "<div style=\"margin-top:18px;color:#64748b\">{$signature}</div>" : '');
|
||
|
||
[$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 <strong>#{ticket_number}</strong> („{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}'
|
||
. '<div style="height:8px;line-height:8px;font-size:0;"> </div>'
|
||
. 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 <<<HTML
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 10px;">
|
||
<tr>
|
||
<td align="{$align}">
|
||
<div style="font-size:11px;color:#94a3b8;margin-bottom:3px;">{$authorLabel} · {$time}</div>
|
||
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width:80%;display:inline-block;">
|
||
<tr>
|
||
<td style="background:{$bg};color:{$color};border-radius:10px;padding:8px 12px;font-size:14px;line-height:1.5;text-align:left;">{$safeMessage}</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
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(['<br>', '<br/>', '<br />', '</p>'], "\n", $rawMessage)), ENT_QUOTES | ENT_HTML5, 'UTF-8'))
|
||
: $rawMessage;
|
||
$rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff);
|
||
}
|
||
$transcriptBlock = '<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:0 0 20px;">' . $rows . '</div>';
|
||
|
||
[$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<string> 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);
|
||
}
|