| Server IP : 202.61.199.114 / Your IP : 216.73.217.139 Web Server : nginx/1.22.1 System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64 User : web20 ( 1018) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /var/www/seaside.pacim.de/web/ |
Upload File : |
<?php
/**
* mail-api.php – Admin-API für das zentrale Mail-System (Phase 1).
* Nur Admins. SMTP-Einstellungen, Test, Template-Verwaltung, Vorschau, Testversand.
* Der produktive Versandweg bleibt extern, bis mail_transport=smtp gesetzt wird.
*/
require_once( __DIR__ . '/includes/config.inc.php' );
@ini_set( 'display_errors', '0' );
error_reporting( 0 );
ob_start();
function ml_json( $a ) { while ( ob_get_level() > 0 ) ob_end_clean(); header( 'Content-Type: application/json; charset=utf-8' ); echo json_encode( $a ); exit(); }
function ml_fail( $m ) { ml_json( array( 'ok' => false, 'error' => $m ) ); }
if ( ! function_exists( 'is_admin' ) || ! is_admin() ) { http_response_code( 403 ); ml_json( array( 'ok' => false, 'error' => 'forbidden' ) ); }
$action = isset( $_GET['action'] ) ? $_GET['action'] : ( isset( $_POST['action'] ) ? $_POST['action'] : '' );
$db = new MysqliDb( MYSQL_HOST, MYSQL_USER, MYSQL_PASS, MYSQL_DB );
/** Datensatz-Domäne eines Auslösers (bestimmt, welche Datensätze die Regel-Check-Suche zeigt). */
function mlrc_domain( $trigger ) {
if ( in_array( $trigger, array( 'partner_signup', 'partner_approved' ), true ) ) return 'partner';
if ( $trigger === 'request_received_contact' ) return 'contact'; // nur Kontaktanfragen
if ( $trigger === 'request_received_period' ) return 'period'; // nur Terminwünsche
if ( $trigger === 'request_status' ) return 'request'; // Kontaktanfragen + Terminwünsche
return 'booking'; // booking_created, payment_received, cancellation, before/after_*
}
/** SQL-Bedingung für die Anfrage-Art je Domäne (optional mit Tabellen-Präfix, z. B. 'r.'). */
function mlrc_request_type_sql( $domain, $p = '' ) {
if ( $domain === 'contact' ) return " AND {$p}request_type='contact'";
if ( $domain === 'period' ) return " AND {$p}request_type='period'";
return " AND {$p}request_type IN ('contact','period')"; // 'request'
}
/* ============================ Einstellungen ============================ */
if ( $action === 'settings' ) {
$c = mailservice::config();
ml_json( array( 'ok' => true, 'settings' => array(
'transport' => $c['transport'],
'host' => $c['host'],
'port' => $c['port'],
'secure' => $c['secure'],
'auth' => $c['auth'] ? '1' : '0',
'user' => $c['user'],
'pass_set' => ( pacim_setting( 'mail_smtp_pass', '' ) !== '' ), // nie das Passwort ausliefern
'from_email' => $c['from_email'],
'from_name' => $c['from_name'],
'header' => pacim_setting( 'mail_header_html', '' ),
'footer' => pacim_setting( 'mail_footer_html', '' ),
'nav_map' => pacim_setting( 'mail_nav_url_map', 'https://map.pacim.de' ),
'nav_valet' => pacim_setting( 'mail_nav_url_valet', 'https://valet.pacim.de' ),
'nav_addr_map' => pacim_setting( 'mail_nav_addr_map', 'An der Werft 3, 18119 Warnemünde' ),
'nav_addr_valet' => pacim_setting( 'mail_nav_addr_valet', 'Am Passagierkai 3, 18119 Warnemünde' ),
) ) );
}
if ( $action === 'settings-save' ) {
// Nur tatsächlich übermittelte Felder aktualisieren (so überschreiben sich
// SMTP-Formular und Header/Footer-Formular nicht gegenseitig).
$set = function ( $k, $v ) { settings::set( $k, (string) $v ); };
if ( isset( $_POST['transport'] ) ) $set( 'mail_transport', in_array( $_POST['transport'], array( 'external', 'smtp' ), true ) ? $_POST['transport'] : 'external' );
if ( isset( $_POST['host'] ) ) $set( 'mail_smtp_host', trim( (string) $_POST['host'] ) );
if ( isset( $_POST['port'] ) ) $set( 'mail_smtp_port', (int) $_POST['port'] );
if ( isset( $_POST['secure'] ) ) $set( 'mail_smtp_secure', in_array( $_POST['secure'], array( '', 'tls', 'ssl' ), true ) ? $_POST['secure'] : '' );
if ( isset( $_POST['user'] ) ) $set( 'mail_smtp_user', trim( (string) $_POST['user'] ) );
if ( isset( $_POST['from_email'] ) ) $set( 'mail_from_email', trim( (string) $_POST['from_email'] ) );
if ( isset( $_POST['from_name'] ) ) $set( 'mail_from_name', trim( (string) $_POST['from_name'] ) );
if ( isset( $_POST['header'] ) ) $set( 'mail_header_html', (string) $_POST['header'] );
if ( isset( $_POST['footer'] ) ) $set( 'mail_footer_html', (string) $_POST['footer'] );
if ( isset( $_POST['nav_map'] ) ) $set( 'mail_nav_url_map', trim( (string) $_POST['nav_map'] ) );
if ( isset( $_POST['nav_valet'] ) ) $set( 'mail_nav_url_valet', trim( (string) $_POST['nav_valet'] ) );
if ( isset( $_POST['nav_addr_map'] ) ) $set( 'mail_nav_addr_map', trim( (string) $_POST['nav_addr_map'] ) );
if ( isset( $_POST['nav_addr_valet'] ) ) $set( 'mail_nav_addr_valet', trim( (string) $_POST['nav_addr_valet'] ) );
// auth nur mitschreiben, wenn das SMTP-Formular gesendet wurde (Marker-Feld)
if ( isset( $_POST['smtpform'] ) ) $set( 'mail_smtp_auth', ! empty( $_POST['auth'] ) ? '1' : '0' );
if ( isset( $_POST['pass'] ) && $_POST['pass'] !== '' ) settings::set( 'mail_smtp_pass', pacim_encrypt( (string) $_POST['pass'] ) );
ml_json( array( 'ok' => true ) );
}
if ( $action === 'test-connect' ) { ml_json( array_merge( array( 'ok' => false ), mailservice::testConnect() ) ); }
if ( $action === 'test-mail' ) {
$to = trim( (string) ( $_POST['to'] ?? '' ) );
if ( ! filter_var( $to, FILTER_VALIDATE_EMAIL ) ) ml_fail( 'Bitte gültige Empfängeradresse angeben.' );
$html = mailrenderer::layout( '<p>Dies ist eine Testmail aus dem PaCIM Mail-System.</p><p>Gesendet: ' . date( 'd.m.Y H:i' ) . ' Uhr</p>', null );
$res = mailservice::send( $to, '', 'PaCIM Testmail', $html );
ml_json( array( 'ok' => $res['ok'], 'error' => isset( $res['error'] ) ? $res['error'] : null ) );
}
/* ============================ E-Mail-Konten ============================ */
if ( $action === 'accounts' ) {
$rows = $db->rawQuery( "SELECT account_id, account_name, account_host, account_from_email, account_active FROM pacim_mail_accounts ORDER BY account_name" );
ml_json( array( 'ok' => true, 'accounts' => $rows ? $rows : array() ) );
}
if ( $action === 'account' ) {
$a = $db->rawQueryOne( "SELECT * FROM pacim_mail_accounts WHERE account_id=?", array( (int) ( $_GET['id'] ?? 0 ) ) );
if ( ! $a ) ml_fail( 'not_found' );
$a['pass_set'] = ( $a['account_pass'] !== '' );
unset( $a['account_pass'] ); // Passwort nie ausliefern
ml_json( array( 'ok' => true, 'account' => $a ) );
}
if ( $action === 'account-save' ) {
$id = (int) ( $_POST['id'] ?? 0 );
$name = trim( (string) ( $_POST['name'] ?? '' ) );
if ( $name === '' ) ml_fail( 'Bitte einen Kontonamen angeben.' );
$fields = array(
'account_name' => $name,
'account_host' => trim( (string) ( $_POST['host'] ?? '' ) ),
'account_port' => (int) ( $_POST['port'] ?? 587 ),
'account_secure' => in_array( ( $_POST['secure'] ?? '' ), array( '', 'tls', 'ssl' ), true ) ? (string) $_POST['secure'] : '',
'account_auth' => ! empty( $_POST['auth'] ) ? 1 : 0,
'account_user' => trim( (string) ( $_POST['user'] ?? '' ) ),
'account_from_email' => trim( (string) ( $_POST['from_email'] ?? '' ) ),
'account_from_name' => trim( (string) ( $_POST['from_name'] ?? '' ) ),
'account_active' => isset( $_POST['active'] ) ? ( ! empty( $_POST['active'] ) ? 1 : 0 ) : 1,
'account_updated' => date( 'Y-m-d H:i:s' ),
);
if ( isset( $_POST['pass'] ) && $_POST['pass'] !== '' ) $fields['account_pass'] = pacim_encrypt( (string) $_POST['pass'] );
if ( ! $id ) {
$fields['account_created'] = date( 'Y-m-d H:i:s' );
$nid = $db->insert( 'pacim_mail_accounts', $fields );
if ( ! $nid ) ml_fail( 'Konto konnte nicht angelegt werden.' );
ml_json( array( 'ok' => true, 'id' => (int) $nid ) );
}
$db->where( 'account_id', $id ); $db->update( 'pacim_mail_accounts', $fields );
ml_json( array( 'ok' => true, 'id' => $id ) );
}
if ( $action === 'account-delete' ) {
$id = (int) ( $_POST['id'] ?? 0 ); if ( $id <= 0 ) ml_fail( 'not_found' );
$db->where( 'account_id', $id ); $db->delete( 'pacim_mail_accounts' );
// Regeln/Kampagnen, die dieses Konto nutzten, auf Standard zurücksetzen
$db->rawQuery( "UPDATE pacim_mail_rules SET rule_account=0 WHERE rule_account=?", array( $id ) );
$db->rawQuery( "UPDATE pacim_newsletter_campaign SET campaign_account=0 WHERE campaign_account=?", array( $id ) );
ml_json( array( 'ok' => true ) );
}
if ( $action === 'account-test' ) {
$id = (int) ( $_POST['id'] ?? 0 );
$to = trim( (string) ( $_POST['to'] ?? '' ) );
if ( $to !== '' ) {
if ( ! filter_var( $to, FILTER_VALIDATE_EMAIL ) ) ml_fail( 'Bitte gültige Empfängeradresse angeben.' );
$html = mailrenderer::layout( '<p>Testmail aus dem PaCIM Mail-System (Konto-Test).</p><p>Gesendet: ' . date( 'd.m.Y H:i' ) . ' Uhr</p>', null );
$res = mailservice::send( $to, '', 'PaCIM Konto-Testmail', $html, array(), array( 'account' => $id ) );
ml_json( array( 'ok' => $res['ok'], 'error' => isset( $res['error'] ) ? $res['error'] : null ) );
}
ml_json( array_merge( array( 'ok' => false ), mailservice::testConnect( $id ) ) );
}
/* ============================ Templates ============================ */
if ( $action === 'templates' ) {
$db->rawQuery( "ALTER TABLE pacim_templates ADD COLUMN IF NOT EXISTS template_booking TINYINT(1) NOT NULL DEFAULT 0" );
$rows = $db->rawQuery( "SELECT template_id, template_key, template_name, template_title, template_type, template_active, template_admin, template_booking FROM pacim_templates ORDER BY template_name ASC" );
$out = array();
foreach ( (array) $rows as $r ) $out[] = array(
'id' => (int) $r['template_id'], 'key' => $r['template_key'], 'name' => $r['template_name'],
'subject' => $r['template_title'], 'type' => $r['template_type'] ?: 'system',
'active' => (int) $r['template_active'], 'admin' => (int) $r['template_admin'],
'booking' => (int) $r['template_booking'],
);
ml_json( array( 'ok' => true, 'templates' => $out, 'placeholders' => mailrenderer::placeholderCatalog() ) );
}
if ( $action === 'template' ) {
$id = (int) ( $_GET['id'] ?? 0 );
$r = $db->rawQueryOne( "SELECT * FROM pacim_templates WHERE template_id = ?", array( $id ) );
if ( ! $r ) ml_fail( 'not_found' );
$att = $db->rawQuery( "SELECT attachment_id, attachment_name, attachment_path, attachment_filetype FROM pacim_templates_attachments WHERE attachment_template = ? ORDER BY attachment_id", array( $id ) );
ml_json( array( 'ok' => true, 'template' => $r, 'attachments' => $att ? $att : array(), 'presets' => mailrenderer::attachmentPresets() ) );
}
/* ---------- Anhänge (pacim_templates_attachments) ---------- */
if ( $action === 'attachment-add' ) {
$tid = (int) ( $_POST['template_id'] ?? 0 );
if ( $tid <= 0 ) ml_fail( 'Bitte das Template zuerst speichern.' );
$presets = mailrenderer::attachmentPresets();
$preset = (string) ( $_POST['preset'] ?? '' );
if ( $preset !== '' && isset( $presets[ $preset ] ) ) {
$p = $presets[ $preset ];
$name = $p['name']; $path = $p['path']; $type = $p['filetype'];
} else {
$name = trim( (string) ( $_POST['name'] ?? '' ) );
$path = trim( (string) ( $_POST['path'] ?? '' ) );
$type = trim( (string) ( $_POST['filetype'] ?? 'pdf' ) );
if ( $path === '' ) ml_fail( 'Bitte einen Pfad oder ein Dokument wählen.' );
if ( $name === '' ) $name = basename( $path );
}
$newId = $db->insert( 'pacim_templates_attachments', array(
'attachment_template' => $tid, 'attachment_name' => $name, 'attachment_path' => $path, 'attachment_filetype' => $type,
) );
if ( ! $newId ) ml_fail( 'Anhang konnte nicht hinzugefügt werden.' );
ml_json( array( 'ok' => true, 'id' => (int) $newId ) );
}
if ( $action === 'attachment-delete' ) {
$aid = (int) ( $_POST['id'] ?? 0 );
if ( $aid <= 0 ) ml_fail( 'not_found' );
$db->where( 'attachment_id', $aid );
$db->delete( 'pacim_templates_attachments' );
ml_json( array( 'ok' => true ) );
}
/* ---------- Datei-Upload für Anhänge (Template + Newsletter) ---------- */
if ( $action === 'mail-upload' ) {
if ( ! isset( $_FILES['file'] ) || ! is_array( $_FILES['file'] ) || $_FILES['file']['error'] !== UPLOAD_ERR_OK ) ml_fail( 'Upload fehlgeschlagen.' );
$f = $_FILES['file'];
if ( (int) $f['size'] <= 0 ) ml_fail( 'Leere Datei.' );
if ( (int) $f['size'] > 15 * 1024 * 1024 ) ml_fail( 'Datei zu groß (max. 15 MB).' );
$orig = (string) $f['name'];
$ext = strtolower( pathinfo( $orig, PATHINFO_EXTENSION ) );
$allowed = array( 'pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'zip', 'ics' );
if ( ! in_array( $ext, $allowed, true ) ) ml_fail( 'Dateityp nicht erlaubt (' . htmlspecialchars( $ext ) . ').' );
$dir = rtrim( PATH_DATAS, '/' ) . '/mailing/uploads';
if ( ! is_dir( $dir ) ) @mkdir( $dir, 0775, true );
$safe = preg_replace( '/[^A-Za-z0-9._-]/', '_', $orig );
$fname = date( 'Ymd_His' ) . '_' . substr( md5( uniqid( '', true ) ), 0, 8 ) . '_' . $safe;
$dest = $dir . '/' . $fname;
if ( ! @move_uploaded_file( $f['tmp_name'], $dest ) ) ml_fail( 'Datei konnte nicht gespeichert werden.' );
@chmod( $dest, 0644 );
ml_json( array( 'ok' => true, 'path' => 'mailing/uploads/' . $fname, 'name' => $orig, 'filetype' => $ext, 'size' => (int) $f['size'] ) );
}
if ( $action === 'template-save' ) {
$db->rawQuery( "ALTER TABLE pacim_templates ADD COLUMN IF NOT EXISTS template_booking TINYINT(1) NOT NULL DEFAULT 0" );
$id = (int) ( $_POST['id'] ?? 0 );
$name = trim( (string) ( $_POST['name'] ?? '' ) );
$subject = trim( (string) ( $_POST['subject'] ?? '' ) );
if ( $name === '' ) ml_fail( 'Bitte einen Namen angeben.' );
$key = trim( (string) ( $_POST['key'] ?? '' ) );
$key = $key === '' ? null : preg_replace( '/[^a-z0-9_]/', '', strtolower( $key ) );
// Key-Eindeutigkeit
if ( $key ) {
$ex = $db->rawQueryOne( "SELECT template_id FROM pacim_templates WHERE template_key = ?" . ( $id ? " AND template_id <> " . $id : "" ), array( $key ) );
if ( $ex ) ml_fail( 'Der Schlüssel ist bereits vergeben.' );
}
$fields = array(
'template_key' => $key,
'template_name' => $name,
'template_title' => $subject,
'template_content' => (string) ( $_POST['content'] ?? '' ),
'template_plain' => (string) ( $_POST['plain'] ?? '' ),
'template_plain_auto' => ! empty( $_POST['plain_auto'] ) ? 1 : 0,
'template_type' => in_array( ( $_POST['type'] ?? 'system' ), array( 'system', 'newsletter', 'both' ), true ) ? ( $_POST['type'] ?? 'system' ) : 'system',
'template_active' => ! empty( $_POST['active'] ) ? 1 : 0,
'template_booking' => ! empty( $_POST['booking'] ) ? 1 : 0,
'template_layout' => ! empty( $_POST['layout'] ) ? 1 : 0,
'template_header' => (string) ( $_POST['header'] ?? '' ),
'template_footer' => (string) ( $_POST['footer'] ?? '' ),
'template_updated' => date( 'Y-m-d H:i:s' ),
);
if ( ! $id ) {
$newId = $db->insert( 'pacim_templates', $fields );
if ( ! $newId ) ml_fail( 'Template konnte nicht angelegt werden.' );
ml_json( array( 'ok' => true, 'id' => (int) $newId ) );
} else {
$db->where( 'template_id', $id );
$db->update( 'pacim_templates', $fields );
ml_json( array( 'ok' => true, 'id' => $id ) );
}
}
if ( $action === 'template-delete' ) {
$id = (int) ( $_POST['id'] ?? 0 );
if ( $id <= 0 ) ml_fail( 'not_found' );
$db->where( 'template_id', $id );
$db->delete( 'pacim_templates' );
ml_json( array( 'ok' => true ) );
}
if ( $action === 'template-preview' ) {
// entweder gespeichertes Template (id) oder Live-Inhalt aus dem Editor
$invoiceId = (int) ( $_POST['invoice_id'] ?? 0 );
if ( ! empty( $_POST['id'] ) ) {
$t = $db->rawQueryOne( "SELECT * FROM pacim_templates WHERE template_id = ?", array( (int) $_POST['id'] ) );
if ( ! $t ) ml_fail( 'not_found' );
$t = (object) $t;
} else {
$t = (object) array(
'template_title' => (string) ( $_POST['subject'] ?? '' ),
'template_content' => (string) ( $_POST['content'] ?? '' ),
'template_layout' => ! empty( $_POST['layout'] ) ? 1 : 0,
'template_header' => (string) ( $_POST['header'] ?? '' ),
'template_footer' => (string) ( $_POST['footer'] ?? '' ),
'template_plain' => (string) ( $_POST['plain'] ?? '' ),
'template_plain_auto' => ! empty( $_POST['plain_auto'] ) ? 1 : 0,
);
}
$r = mailrenderer::render( $t, mailrenderer::context( $invoiceId, true ) ); // Editor-Vorschau → Beispieldaten
ml_json( array( 'ok' => true, 'subject' => $r['subject'], 'html' => $r['html'], 'plain' => $r['plain'] ) );
}
if ( $action === 'template-test' ) {
$to = trim( (string) ( $_POST['to'] ?? '' ) );
if ( ! filter_var( $to, FILTER_VALIDATE_EMAIL ) ) ml_fail( 'Bitte gültige Empfängeradresse angeben.' );
$invoiceId = (int) ( $_POST['invoice_id'] ?? 0 );
if ( ! empty( $_POST['id'] ) ) {
$t = $db->rawQueryOne( "SELECT * FROM pacim_templates WHERE template_id = ?", array( (int) $_POST['id'] ) );
if ( ! $t ) ml_fail( 'not_found' );
$t = (object) $t;
} else {
$t = (object) array( 'template_title' => (string) ( $_POST['subject'] ?? '' ), 'template_content' => (string) ( $_POST['content'] ?? '' ),
'template_layout' => ! empty( $_POST['layout'] ) ? 1 : 0, 'template_header' => (string) ( $_POST['header'] ?? '' ), 'template_footer' => (string) ( $_POST['footer'] ?? '' ),
'template_plain' => (string) ( $_POST['plain'] ?? '' ), 'template_plain_auto' => ! empty( $_POST['plain_auto'] ) ? 1 : 0 );
}
$ctx = mailrenderer::context( $invoiceId, true ); // Testversand → Beispieldaten, wenn keine Rechnung gewählt
$r = mailrenderer::render( $t, $ctx );
$files = array(); $missing = array();
if ( ! empty( $_POST['id'] ) ) {
$att = mailrenderer::resolveAttachments( (int) $_POST['id'], $ctx );
$files = $att['files']; $missing = $att['missing'];
}
$res = mailservice::send( $to, '', '[TEST] ' . $r['subject'], $r['html'], $files, array( 'plain' => $r['plain'] ) );
ml_json( array( 'ok' => $res['ok'], 'error' => isset( $res['error'] ) ? $res['error'] : null,
'attached' => count( $files ), 'missing' => $missing ) );
}
/* ============================ Regeln ============================ */
if ( $action === 'rules' ) {
$rows = $db->rawQuery( "SELECT r.*, t.template_name FROM pacim_mail_rules r LEFT JOIN pacim_templates t ON t.template_id=r.rule_template ORDER BY r.rule_id DESC" );
$tpls = $db->rawQuery( "SELECT template_id, template_name FROM pacim_templates WHERE template_active=1 ORDER BY template_name" );
$out = array();
foreach ( (array) $rows as $r ) $out[] = array(
'id' => (int) $r['rule_id'], 'name' => $r['rule_name'], 'active' => (int) $r['rule_active'],
'trigger' => $r['rule_trigger'], 'offset' => (int) $r['rule_offset'],
'send_offset' => (int) $r['rule_send_offset'], 'send_time' => (string) $r['rule_send_time'],
'account' => (int) $r['rule_account'], 'recipient' => (string) $r['rule_recipient'], 'bcc' => (string) $r['rule_bcc'],
'template' => (int) $r['rule_template'],
'template_name' => $r['template_name'], 'logic' => $r['rule_logic'],
'filters' => $r['rule_filters'] ? json_decode( $r['rule_filters'], true ) : array(),
);
ml_json( array( 'ok' => true, 'rules' => $out, 'triggers' => mailrules::triggers(), 'fields' => mailrules::fields(), 'trigger_fields' => mailrules::triggerFields(), 'templates' => $tpls ? $tpls : array(), 'accounts' => mailservice::accountsList() ) );
}
if ( $action === 'rule-save' ) {
$id = (int) ( $_POST['id'] ?? 0 );
$name = trim( (string) ( $_POST['name'] ?? '' ) );
$trigger = (string) ( $_POST['trigger'] ?? '' );
$tpl = (int) ( $_POST['template'] ?? 0 );
if ( $name === '' ) ml_fail( 'Bitte einen Namen angeben.' );
if ( ! array_key_exists( $trigger, mailrules::triggers() ) ) ml_fail( 'Ungültiger Auslöser.' );
if ( $tpl <= 0 ) ml_fail( 'Bitte ein Template wählen.' );
$filters = array();
if ( isset( $_POST['filters'] ) && $_POST['filters'] !== '' ) {
$dec = json_decode( (string) $_POST['filters'], true );
if ( is_array( $dec ) ) {
$fl = mailrules::fields();
foreach ( $dec as $f ) {
$field = isset( $f['field'] ) ? $f['field'] : '';
if ( ! isset( $fl[ $field ] ) ) continue;
$val = isset( $f['value'] ) ? $f['value'] : '';
if ( is_array( $val ) ) { $val = array_values( array_map( 'strval', $val ) ); } else { $val = (string) $val; }
$filters[] = array( 'field' => $field, 'op' => isset( $f['op'] ) ? $f['op'] : 'eq', 'value' => $val );
}
}
}
$fields = array(
'rule_name' => $name,
'rule_active' => ! empty( $_POST['active'] ) ? 1 : 0,
'rule_trigger' => $trigger,
'rule_offset' => (int) ( $_POST['offset'] ?? 0 ),
'rule_send_offset' => max( 0, (int) ( $_POST['send_offset'] ?? 0 ) ),
'rule_send_time' => preg_match( '/^([01]?\d|2[0-3]):([0-5]\d)$/', (string) ( $_POST['send_time'] ?? '' ) ) ? (string) $_POST['send_time'] : '',
'rule_account' => max( 0, (int) ( $_POST['account'] ?? 0 ) ),
'rule_recipient' => filter_var( trim( (string) ( $_POST['recipient'] ?? '' ) ), FILTER_VALIDATE_EMAIL ) ? trim( (string) $_POST['recipient'] ) : '',
'rule_bcc' => implode( ', ', array_filter( array_map( 'trim', preg_split( '/[\s,;]+/', (string) ( $_POST['bcc'] ?? '' ) ) ), function ( $e ) { return $e !== '' && filter_var( $e, FILTER_VALIDATE_EMAIL ); } ) ),
'rule_template' => $tpl,
'rule_logic' => ( ( $_POST['logic'] ?? 'and' ) === 'or' ) ? 'or' : 'and',
'rule_filters' => json_encode( $filters, JSON_UNESCAPED_UNICODE ),
'rule_updated' => date( 'Y-m-d H:i:s' ),
);
if ( ! $id ) {
$fields['rule_created'] = date( 'Y-m-d H:i:s' );
$newId = $db->insert( 'pacim_mail_rules', $fields );
if ( ! $newId ) ml_fail( 'Regel konnte nicht angelegt werden.' );
ml_json( array( 'ok' => true, 'id' => (int) $newId ) );
} else {
$db->where( 'rule_id', $id );
$db->update( 'pacim_mail_rules', $fields );
ml_json( array( 'ok' => true, 'id' => $id ) );
}
}
if ( $action === 'rule-delete' ) {
$id = (int) ( $_POST['id'] ?? 0 );
if ( $id <= 0 ) ml_fail( 'not_found' );
$db->where( 'rule_id', $id );
$db->delete( 'pacim_mail_rules' );
ml_json( array( 'ok' => true ) );
}
/* Regel-Check: prüft für eine oder mehrere Rechnungs-IDs, ob die Regel greifen würde,
* und zeigt die Beispiel-Mail (gerendert mit echten Daten), die erzeugt würde. */
if ( $action === 'rule-check' ) {
$rid = (int) ( $_POST['id'] ?? ( $_GET['id'] ?? 0 ) );
// Datensatz-IDs: bevorzugt 'refs' (Partner/Kontakt/Buchung), alt 'invoices' (nur Buchung).
$raw = (string) ( $_POST['refs'] ?? ( $_POST['invoices'] ?? ( $_GET['refs'] ?? ( $_GET['invoices'] ?? '' ) ) ) );
$rule = $db->rawQueryOne( "SELECT * FROM pacim_mail_rules WHERE rule_id=?", array( $rid ) );
if ( ! $rule ) ml_fail( 'Regel nicht gefunden.' );
$rule['filters'] = $rule['rule_filters'] ? json_decode( $rule['rule_filters'], true ) : array();
$domain = mlrc_domain( (string) $rule['rule_trigger'] );
$ids = array();
foreach ( preg_split( '/[\s,;]+/', $raw ) as $x ) { $x = (int) trim( $x ); if ( $x > 0 && ! in_array( $x, $ids, true ) ) $ids[] = $x; }
if ( ! count( $ids ) ) ml_fail( 'Bitte mindestens einen Datensatz auswählen.' );
$ids = array_slice( $ids, 0, 25 );
$tpl = $rule['rule_template'] ? (object) $db->rawQueryOne( "SELECT * FROM pacim_templates WHERE template_id=?", array( (int) $rule['rule_template'] ) ) : null;
$sched = mailrules::computeScheduled( isset( $rule['rule_send_offset'] ) ? $rule['rule_send_offset'] : 0, isset( $rule['rule_send_time'] ) ? $rule['rule_send_time'] : '' );
$recip = trim( (string) $rule['rule_recipient'] );
// commit=1 → „scharf schalten": Mails werden tatsächlich in die Warteschlange gelegt (Versand per Cron).
// Ohne commit = Dry-Run (nur Vorschau). Voraussetzung: aktives Template + SMTP-Versandweg.
$commit = ! empty( $_POST['commit'] );
if ( $commit && ( ! $tpl || (int) $tpl->template_active !== 1 ) ) ml_fail( 'Kein aktives Template – Mails können nicht erzeugt werden.' );
if ( $commit && class_exists( 'mailservice' ) && ! mailservice::smtpEnabled() ) ml_fail( 'Versandweg ist nicht auf SMTP gestellt – es werden keine Mails erzeugt.' );
$acct = isset( $rule['rule_account'] ) ? (int) $rule['rule_account'] : 0;
$ruleBcc = ( isset( $rule['rule_bcc'] ) && trim( (string) $rule['rule_bcc'] ) !== '' ) ? trim( (string) $rule['rule_bcc'] ) : '';
$created = 0;
$results = array();
if ( $domain === 'partner' ) {
// Partner-Auslöser (Partnerprogramm): Empfänger/Platzhalter aus Partnerdaten, keine Storno-/Rechnungs-Voraussetzungen.
foreach ( $ids as $bidp ) {
$b = $db->rawQueryOne(
"SELECT b.broker_id, b.broker_company, u.user_firstname, u.user_lastname, u.user_email
FROM pacim_broker b LEFT JOIN pacim_users u ON u.user_id = b.broker_id WHERE b.broker_id=?", array( $bidp ) );
if ( ! $b ) { $results[] = array( 'ref' => $bidp, 'kind' => 'partner', 'found' => false ); continue; }
$nm = trim( $b['user_firstname'] . ' ' . $b['user_lastname'] ); if ( $nm === '' ) $nm = $b['broker_company'] ?: ( '#' . $bidp );
$row = mailrules::contextRow( $db, array() );
$check = mailrules::checkFilters( $rule['filters'], $rule['rule_logic'], $row );
$applies = (bool) $check['match'];
// Dedupe: eine Mail je Template+Partner – bereits erzeugte Mail wird nicht erneut erstellt.
$dedup = (bool) $db->rawQueryOne( "SELECT message_id FROM pacim_messages WHERE message_template=? AND message_broker=? LIMIT 1", array( (int) $rule['rule_template'], (int) $bidp ) );
$res = array(
'ref' => $bidp, 'kind' => 'partner', 'found' => true, 'label' => $nm,
'match' => $applies, 'logic' => $check['logic'], 'details' => $check['details'],
'dedup' => $dedup, 'would_create' => ( $applies && ! $dedup ), 'scheduled' => $sched,
);
if ( $applies && $tpl ) {
$ovr = array_merge(
mailrenderer::partnerPlaceholders( $bidp ),
array(
'customer_firstname' => (string) $b['user_firstname'], 'customer_lastname' => (string) $b['user_lastname'],
'customer_email' => (string) $b['user_email'], 'salutation' => ( $nm !== '' ? $nm : (string) $b['broker_company'] ),
'name' => $nm, 'firstname' => (string) $b['user_firstname'], 'lastname' => (string) $b['user_lastname'], 'email' => (string) $b['user_email'],
) );
$ctx = array_merge( mailrenderer::context( 0 ), $ovr );
$r = mailrenderer::render( $tpl, $ctx );
$to = ( $recip !== '' ) ? $recip : trim( (string) $b['user_email'] );
$att = mailrenderer::resolveAttachments( (int) $rule['rule_template'], $ctx );
$names = array(); foreach ( $att['files'] as $f ) $names[] = $f['name'];
$res['to'] = $to; $res['subject'] = $r['subject']; $res['html'] = $r['html'];
$res['attachments'] = $names; $res['bcc'] = (string) $rule['rule_bcc'];
// Scharf schalten: Mail erzeugen (Dedupe per message_broker greift bei erneutem Lauf).
if ( $commit && $res['would_create'] ) {
$meta = array( 'to' => $to, 'broker_id' => (int) $bidp, 'customer_id' => 0, 'ctx' => $ovr,
'rule' => $rid, 'account' => $acct, 'scheduled' => $sched );
if ( $ruleBcc !== '' ) $meta['bcc'] = $ruleBcc;
if ( mailqueue::enqueue( (int) $rule['rule_template'], $meta ) ) { $res['created'] = true; $created++; }
}
}
$results[] = $res;
}
} elseif ( in_array( $domain, array( 'contact', 'period', 'request' ), true ) ) {
// Anfrage-Auslöser (Kontaktformular / Terminwunsch / Status geändert): Empfänger/Platzhalter
// aus der Anfrage (pacim_booking_requests), keine Storno-/Rechnungs-Voraussetzungen.
$typeSql = mlrc_request_type_sql( $domain, 'r.' );
foreach ( $ids as $reqId ) {
// Terminwünsche haben oft keinen eigenen Namen/E-Mail → über verknüpfte Buchung/Kunde auflösen.
$rq = $db->rawQueryOne(
"SELECT r.request_id, r.request_type, r.request_status, r.request_note, r.request_source, r.request_ip,
r.request_begin, r.request_end, r.request_invoice, r.request_decision_note, r.request_staff, r.request_decided_at,
DATE_FORMAT(r.request_created,'%d.%m.%Y %H:%i') AS created,
COALESCE(NULLIF(r.request_name,''), NULLIF(TRIM(CONCAT(COALESCE(c.customer_firstname,''),' ',COALESCE(c.customer_lastname,''))),''), c.customer_name) AS request_name,
COALESCE(NULLIF(r.request_email,''), c.customer_email) AS request_email,
d.invoice_customer
FROM pacim_booking_requests r
LEFT JOIN pacim_invoice d ON d.invoice_id = r.request_invoice
LEFT JOIN pacim_customer c ON c.customer_id = d.invoice_customer
WHERE r.request_id=?" . $typeSql, array( $reqId ) );
if ( ! $rq ) { $results[] = array( 'ref' => $reqId, 'kind' => 'request', 'found' => false ); continue; }
$invId = (int) $rq['request_invoice'];
$custId= (int) ( $rq['invoice_customer'] ?? 0 );
$nm = $rq['request_name'] ?: $rq['request_email'];
$np = preg_split( '/\s+/', (string) $rq['request_name'], 2 );
// Letzte Antwort dieser Anfrage (für Inhalt + Dedupe je Antwort).
$lastReply = $db->rawQueryOne( "SELECT reply_id, reply_body, reply_staff, reply_created FROM pacim_request_replies WHERE reply_request=? ORDER BY reply_id DESC LIMIT 1", array( (int) $reqId ) );
$replyId = $lastReply ? (int) $lastReply['reply_id'] : 0;
$replyBody = $lastReply ? (string) $lastReply['reply_body'] : (string) ( $rq['request_decision_note'] ?? '' );
// ctx mit den Anfrage-Feldern (inkl. request_type/request_status + Antwort-Inhalt) – für Filter UND Rendern.
$ovr = array(
'request_type' => (string) $rq['request_type'], 'request_note' => (string) $rq['request_note'], 'request_message' => (string) $rq['request_note'],
'request_status' => (string) $rq['request_status'], 'request_begin' => (string) $rq['request_begin'], 'request_end' => (string) $rq['request_end'],
'request_name' => (string) $rq['request_name'], 'request_email' => (string) $rq['request_email'],
'request_source' => (string) $rq['request_source'], 'request_ip' => (string) $rq['request_ip'], 'request_created' => (string) $rq['created'],
'request_decision_note' => $replyBody, 'request_staff' => (string) ( $lastReply ? $lastReply['reply_staff'] : ( $rq['request_staff'] ?? '' ) ), 'request_decided_at' => (string) ( $rq['request_decided_at'] ?? '' ),
'customer_email' => (string) $rq['request_email'], 'customer_firstname' => $np[0] ?? '', 'customer_lastname' => $np[1] ?? '',
'name' => $nm, 'firstname' => $np[0] ?? '', 'lastname' => $np[1] ?? '', 'email' => (string) $rq['request_email'],
'salutation' => 'Guten Tag' . ( $nm !== '' ? ' ' . $nm : '' ),
);
// ctx + (bei Terminwunsch) Rechnungs-/Kundenbezug mitgeben → Filter & Platzhalter korrekt.
$row = mailrules::contextRow( $db, array_merge( array( 'ctx' => $ovr ), $invId > 0 ? array( 'invoice_id' => $invId ) : array() ) );
$check = mailrules::checkFilters( $rule['filters'], $rule['rule_logic'], $row );
$applies = (bool) $check['match'];
// Dedupe: je Antwort (sonst je Anfrage) genau EINE Mail pro Template – verhindert erneutes Senden.
$dedup = mailrules::alreadySent( $db, (int) $rule['rule_template'], array( 'reply_id' => $replyId, 'request_id' => (int) $reqId ) );
$res = array(
'ref' => $reqId, 'kind' => 'request', 'found' => true, 'label' => $nm,
'match' => $applies, 'logic' => $check['logic'], 'details' => $check['details'],
'dedup' => $dedup, 'would_create' => ( $applies && ! $dedup ), 'scheduled' => $sched,
);
if ( $applies && $tpl ) {
$ctx = array_merge( mailrenderer::context( $invId ), $ovr ); // Terminwunsch: volle Buchungs-/Kundendaten; Kontakt: invId=0
$r = mailrenderer::render( $tpl, $ctx );
$to = ( $recip !== '' ) ? $recip : trim( (string) $rq['request_email'] );
$att = mailrenderer::resolveAttachments( (int) $rule['rule_template'], $ctx );
$names = array(); foreach ( $att['files'] as $f ) $names[] = $f['name'];
$res['to'] = $to; $res['subject'] = $r['subject']; $res['html'] = $r['html'];
$res['attachments'] = $names; $res['bcc'] = (string) $rule['rule_bcc'];
if ( $commit && $res['would_create'] ) {
$meta = array( 'to' => $to, 'customer_id' => $custId, 'ctx' => $ovr,
'rule' => $rid, 'account' => $acct, 'scheduled' => $sched, 'request_id' => (int) $reqId, 'reply_id' => $replyId );
if ( $invId > 0 ) $meta['invoice_id'] = $invId;
if ( $ruleBcc !== '' ) $meta['bcc'] = $ruleBcc;
if ( mailqueue::enqueue( (int) $rule['rule_template'], $meta ) ) { $res['created'] = true; $created++; }
}
}
$results[] = $res;
}
} else {
// Buchungs-Auslöser (Standard): ref = Rechnungs-ID.
foreach ( $ids as $iid ) {
$inv = $db->rawQueryOne(
"SELECT d.invoice_id, d.invoice_customer, c.customer_email, TRIM(BOTH ', ' FROM CONCAT(c.customer_lastname,', ',c.customer_firstname)) AS cname,
(SELECT b.booking_id FROM pacim_booking b WHERE b.booking_invoice=d.invoice_id ORDER BY b.booking_id ASC LIMIT 1) AS booking_id
FROM pacim_invoice d LEFT JOIN pacim_customer c ON c.customer_id=d.invoice_customer
WHERE d.invoice_id=?", array( $iid ) );
if ( ! $inv ) { $results[] = array( 'ref' => $iid, 'invoice_id' => $iid, 'kind' => 'booking', 'found' => false ); continue; }
$meta = array( 'invoice_id' => $iid, 'booking_id' => (int) $inv['booking_id'], 'customer_id' => (int) $inv['invoice_customer'] );
$row = mailrules::contextRow( $db, $meta );
$check = mailrules::checkFilters( $rule['filters'], $rule['rule_logic'], $row );
$pre = mailrules::preconditions( $db, $rule, $row ); // Storno / Rechnungsnummer
$preOk = true; foreach ( $pre as $p ) { if ( ! $p['ok'] ) { $preOk = false; break; } }
$applies = ( $check['match'] && $preOk );
$details = array_merge( $pre, $check['details'] ); // Voraussetzungen zuerst anzeigen
$dedup = ( (int) $inv['booking_id'] > 0 ) && (bool) $db->rawQueryOne( "SELECT message_id FROM pacim_messages WHERE message_template=? AND message_booking=? LIMIT 1", array( (int) $rule['rule_template'], (int) $inv['booking_id'] ) );
$lbl = trim( (string) $inv['cname'] ); if ( $lbl === '' ) $lbl = 'Rechnung #' . $iid;
$res = array(
'ref' => $iid, 'invoice_id' => $iid, 'kind' => 'booking', 'found' => true, 'label' => $lbl,
'match' => $applies, 'logic' => $check['logic'], 'details' => $details,
'dedup' => $dedup, 'would_create' => ( $applies && ! $dedup ),
'scheduled' => $sched,
);
if ( $applies && $tpl ) {
$ctx = mailrenderer::context( $iid );
$r = mailrenderer::render( $tpl, $ctx );
$to = ( $recip !== '' ) ? $recip : trim( (string) $inv['customer_email'] );
$att = mailrenderer::resolveAttachments( (int) $rule['rule_template'], $ctx );
$names = array();
foreach ( $att['files'] as $f ) $names[] = $f['name'];
$res['to'] = $to; $res['subject'] = $r['subject']; $res['html'] = $r['html'];
$res['attachments'] = $names; $res['bcc'] = (string) $rule['rule_bcc'];
if ( $commit && $res['would_create'] ) {
$meta = $meta + array( 'rule' => $rid, 'account' => $acct, 'scheduled' => $sched );
if ( $recip !== '' ) $meta['to'] = $recip;
if ( $ruleBcc !== '' ) $meta['bcc'] = $ruleBcc;
if ( mailqueue::enqueue( (int) $rule['rule_template'], $meta ) ) { $res['created'] = true; $created++; }
}
}
$results[] = $res;
}
}
ml_json( array(
'ok' => true,
'rule' => array( 'id' => $rid, 'name' => $rule['rule_name'], 'template' => $tpl ? $tpl->template_name : '', 'trigger' => $rule['rule_trigger'], 'domain' => $domain, 'active' => (int) $rule['rule_active'] ),
'results' => $results,
'committed' => $commit, 'created' => $created,
) );
}
/* ============================ Versand-Warteschlange ============================ */
/* Endgültiges Löschen einzelner Mails aus der Warteschlange (nur Status 5/6). */
if ( $action === 'queue-delete' ) {
$raw = (string) ( $_POST['ids'] ?? ( $_POST['id'] ?? '' ) );
$ids = array();
foreach ( preg_split( '/[\s,;]+/', $raw ) as $x ) { $x = (int) trim( $x ); if ( $x > 0 ) $ids[] = $x; }
if ( ! count( $ids ) ) ml_fail( 'Keine Mail ausgewählt.' );
$in = implode( ',', array_map( 'intval', $ids ) );
$db->rawQuery( "DELETE FROM pacim_messages WHERE message_status IN (5,6) AND message_id IN ($in)" );
ml_json( array( 'ok' => true, 'deleted' => (int) $db->count ) );
}
/* Endgültiges Löschen ganzer Gruppen: 'failed' (6), 'queued' (5) oder 'all' (5+6). */
if ( $action === 'queue-clear' ) {
$what = (string) ( $_POST['what'] ?? '' );
if ( $what === 'failed' ) $db->rawQuery( "DELETE FROM pacim_messages WHERE message_status = 6" );
elseif ( $what === 'queued' ) $db->rawQuery( "DELETE FROM pacim_messages WHERE message_status = 5" );
elseif ( $what === 'all' ) $db->rawQuery( "DELETE FROM pacim_messages WHERE message_status IN (5,6)" );
else ml_fail( 'Ungültige Auswahl.' );
ml_json( array( 'ok' => true, 'deleted' => (int) $db->count ) );
}
/* Live-Suche für den Regel-Check – je nach Auslöser nur passende Datensätze:
* Partner-Auslöser → Partner, Kontakt-Auslöser → Kontaktanfragen, sonst → Buchungen. */
if ( $action === 'rule-search' ) {
$q = trim( (string) ( $_GET['q'] ?? ( $_POST['q'] ?? '' ) ) );
if ( mb_strlen( $q ) < 2 ) ml_json( array( 'ok' => true, 'results' => array() ) );
$domain = mlrc_domain( (string) ( $_GET['trigger'] ?? ( $_POST['trigger'] ?? '' ) ) );
$like = '%' . mb_strtolower( $q, 'UTF-8' ) . '%';
if ( $domain === 'partner' ) {
$rows = $db->rawQuery(
"SELECT b.broker_id, b.broker_company, b.broker_active, b.broker_pending, u.user_firstname, u.user_lastname, u.user_email
FROM pacim_broker b LEFT JOIN pacim_users u ON u.user_id = b.broker_id
WHERE LOWER(b.broker_company) LIKE ? OR LOWER(u.user_firstname) LIKE ? OR LOWER(u.user_lastname) LIKE ? OR LOWER(u.user_email) LIKE ?
ORDER BY b.broker_id DESC LIMIT 20",
array( $like, $like, $like, $like ) );
$out = array();
foreach ( (array) $rows as $r ) {
$nm = trim( $r['user_firstname'] . ' ' . $r['user_lastname'] ); if ( $nm === '' ) $nm = $r['broker_company'] ?: ( '#' . $r['broker_id'] );
$st = ( (int) $r['broker_active'] === 1 ) ? 'aktiv' : ( (int) $r['broker_pending'] === 1 ? 'in Prüfung' : 'inaktiv' );
$out[] = array( 'ref' => (int) $r['broker_id'], 'kind' => 'partner', 'name' => $nm,
'sub' => trim( ( $r['broker_company'] ? $r['broker_company'] . ' · ' : '' ) . (string) $r['user_email'] ),
'source' => 'partner', 'source_label' => 'Partner · ' . $st, 'storno' => false );
}
ml_json( array( 'ok' => true, 'results' => $out, 'domain' => $domain ) );
}
if ( in_array( $domain, array( 'contact', 'period', 'request' ), true ) ) {
$typeSql = mlrc_request_type_sql( $domain, 'r.' );
// Terminwünsche haben oft keinen eigenen Namen → über die verknüpfte Buchung/Kunde mitsuchen.
$rows = $db->rawQuery(
"SELECT r.request_id, r.request_type,
COALESCE(NULLIF(r.request_name,''), NULLIF(TRIM(CONCAT(COALESCE(c.customer_firstname,''),' ',COALESCE(c.customer_lastname,''))),''), c.customer_name) AS request_name,
COALESCE(NULLIF(r.request_email,''), c.customer_email) AS request_email,
r.request_status, r.request_note, r.request_begin, r.request_end,
DATE_FORMAT(r.request_created,'%d.%m.%Y %H:%i') AS created, b.booking_no, d.invoice_no
FROM pacim_booking_requests r
LEFT JOIN pacim_invoice d ON d.invoice_id = r.request_invoice
LEFT JOIN pacim_customer c ON c.customer_id = d.invoice_customer
LEFT JOIN pacim_booking b ON b.booking_invoice = d.invoice_id
WHERE ( LOWER(r.request_name) LIKE ? OR LOWER(r.request_email) LIKE ? OR LOWER(r.request_note) LIKE ?
OR LOWER(c.customer_name) LIKE ? OR LOWER(CONCAT(COALESCE(c.customer_firstname,''),' ',COALESCE(c.customer_lastname,''))) LIKE ?
OR LOWER(c.customer_email) LIKE ? OR LOWER(b.booking_no) LIKE ? OR LOWER(d.invoice_no) LIKE ? )" . $typeSql . "
GROUP BY r.request_id
ORDER BY r.request_id DESC LIMIT 20",
array( $like, $like, $like, $like, $like, $like, $like, $like ) );
$stMap = array( 'pending' => 'Unbearbeitet', 'done' => 'Erledigt', 'rejected' => 'Abgelehnt', 'approved' => 'Genehmigt' );
$typeMap = array( 'contact' => 'Kontakt', 'period' => 'Terminwunsch' );
$out = array();
foreach ( (array) $rows as $r ) {
$typeLbl = $typeMap[ (string) $r['request_type'] ] ?? (string) $r['request_type'];
$stLbl = $stMap[ (string) $r['request_status'] ] ?? (string) $r['request_status'];
$sub = (string) $r['created'];
if ( (string) $r['request_type'] === 'period' && ! empty( $r['request_begin'] ) ) {
$sub = date( 'd.m.Y', strtotime( (string) $r['request_begin'] ) ) . ( ! empty( $r['request_end'] ) ? '–' . date( 'd.m.Y', strtotime( (string) $r['request_end'] ) ) : '' ) . ' · ' . $sub;
} elseif ( ! empty( $r['request_email'] ) ) {
$sub = $r['request_email'] . ' · ' . $sub;
}
$out[] = array( 'ref' => (int) $r['request_id'], 'kind' => 'request', 'name' => ( $r['request_name'] ?: $r['request_email'] ?: ( '#' . $r['request_id'] ) ),
'sub' => trim( $sub ),
'source' => (string) $r['request_type'], 'source_label' => $typeLbl . ' · ' . $stLbl, 'storno' => false );
}
ml_json( array( 'ok' => true, 'results' => $out, 'domain' => $domain ) );
}
// ---- Domäne Buchung (Standard) ----
$norm = preg_replace( '/[^0-9a-zäöüß]+/u', '', mb_strtolower( $q, 'UTF-8' ) );
$nlike = '%' . $norm . '%';
$sources = function_exists( 'pacim_sources' ) ? pacim_sources() : array();
$srcMap = array();
$matched = array();
foreach ( $sources as $s ) {
$srcMap[ $s['key'] ] = $s['label'];
if ( mb_stripos( $s['key'], $q ) !== false || mb_stripos( $s['label'], $q ) !== false ) $matched[] = $s['key'];
}
$where = "(
REPLACE(REPLACE(REPLACE(LOWER(b.booking_serial),' ',''),'-',''),'_','') LIKE ?
OR REPLACE(REPLACE(REPLACE(LOWER(c.customer_name),' ',''),'-',''),'_','') LIKE ?
OR REPLACE(REPLACE(REPLACE(LOWER(CONCAT(c.customer_firstname,c.customer_lastname)),' ',''),'-',''),'_','') LIKE ?
OR REPLACE(REPLACE(REPLACE(LOWER(CONCAT(c.customer_lastname,c.customer_firstname)),' ',''),'-',''),'_','') LIKE ?
OR REPLACE(REPLACE(REPLACE(LOWER(b.booking_no),' ',''),'-',''),'_','') LIKE ?
OR REPLACE(REPLACE(REPLACE(LOWER(d.invoice_no),' ',''),'-',''),'_','') LIKE ?
OR LOWER(c.customer_source) LIKE ?";
$params = array( $nlike, $nlike, $nlike, $nlike, $nlike, $nlike, '%' . mb_strtolower( $q, 'UTF-8' ) . '%' );
if ( count( $matched ) ) {
$where .= " OR c.customer_source IN (" . implode( ',', array_fill( 0, count( $matched ), '?' ) ) . ")";
foreach ( $matched as $k ) $params[] = $k;
}
$where .= ")";
$rows = $db->rawQuery(
"SELECT b.booking_id, b.booking_no, b.booking_event, d.invoice_id, d.invoice_no, d.invoice_status,
c.customer_firstname, c.customer_lastname, c.customer_name, c.customer_source,
DATE_FORMAT(b.booking_begin,'%d.%m.%Y') AS von
FROM pacim_booking b
LEFT JOIN pacim_invoice d ON d.invoice_id=b.booking_invoice
LEFT JOIN pacim_customer c ON c.customer_id=b.booking_customer
WHERE $where
ORDER BY b.booking_id DESC LIMIT 20", $params );
$out = array();
foreach ( (array) $rows as $r ) {
if ( ! (int) $r['invoice_id'] ) continue;
$name = trim( $r['customer_lastname'] . ', ' . $r['customer_firstname'], ', ' );
if ( $name === '' ) $name = $r['customer_name'] ? $r['customer_name'] : '—';
$src = (string) $r['customer_source'];
$out[] = array(
'ref' => (int) $r['invoice_id'], 'kind' => 'booking',
'invoice_id' => (int) $r['invoice_id'], 'booking_id' => (int) $r['booking_id'],
'name' => $name, 'booking_no' => (string) $r['booking_no'], 'invoice_no' => (string) $r['invoice_no'],
'sub' => trim( implode( ' · ', array_filter( array( $r['booking_no'], $r['invoice_no'], $r['von'], $r['booking_event'] ) ) ) ),
'von' => (string) $r['von'], 'event' => (string) $r['booking_event'],
'source' => $src, 'source_label' => isset( $srcMap[ $src ] ) ? $srcMap[ $src ] : ( $src !== '' ? $src : '—' ),
'storno' => ( (int) $r['invoice_status'] === 3 ),
);
}
ml_json( array( 'ok' => true, 'results' => $out, 'domain' => 'booking' ) );
}
/* ============================ Mailing-Listen ============================ */
if ( $action === 'lists' ) {
$rows = $db->rawQuery(
"SELECT l.list_id, l.list_name, l.list_created,
(SELECT COUNT(*) FROM pacim_mail_list_entries e WHERE e.entry_list=l.list_id) AS cnt,
(SELECT COUNT(*) FROM pacim_mail_list_entries e JOIN pacim_mail_suppression s ON s.email=e.entry_email WHERE e.entry_list=l.list_id) AS muted
FROM pacim_mail_lists l ORDER BY l.list_name" );
ml_json( array( 'ok' => true, 'lists' => $rows ? $rows : array() ) );
}
if ( $action === 'list' ) {
$id = (int) ( $_GET['id'] ?? 0 );
$l = $db->rawQueryOne( "SELECT * FROM pacim_mail_lists WHERE list_id=?", array( $id ) );
if ( ! $l ) ml_fail( 'not_found' );
// Abgemeldete (Sperrliste) als „stummgeschaltet" markieren: Eintrag bleibt erhalten,
// erhält aber keine Mails mehr (candidatesList schließt gesperrte E-Mails aus).
$entries = $db->rawQuery(
"SELECT e.entry_id, e.entry_email, e.entry_firstname, e.entry_lastname, e.entry_company,
( s.suppression_id IS NOT NULL ) AS muted, s.reason AS muted_reason,
DATE_FORMAT( s.created, '%d.%m.%Y' ) AS muted_at
FROM pacim_mail_list_entries e
LEFT JOIN pacim_mail_suppression s ON s.email = e.entry_email
WHERE e.entry_list=? ORDER BY e.entry_id DESC", array( $id ) );
ml_json( array( 'ok' => true, 'list' => $l, 'entries' => $entries ? $entries : array() ) );
}
if ( $action === 'list-save' ) {
$id = (int) ( $_POST['id'] ?? 0 );
$name = trim( (string) ( $_POST['name'] ?? '' ) );
if ( $name === '' ) ml_fail( 'Bitte einen Listennamen angeben.' );
if ( ! $id ) {
$nid = $db->insert( 'pacim_mail_lists', array( 'list_name' => $name, 'list_created' => date( 'Y-m-d H:i:s' ) ) );
if ( ! $nid ) ml_fail( 'Liste konnte nicht angelegt werden.' );
ml_json( array( 'ok' => true, 'id' => (int) $nid ) );
}
$db->where( 'list_id', $id ); $db->update( 'pacim_mail_lists', array( 'list_name' => $name ) );
ml_json( array( 'ok' => true, 'id' => $id ) );
}
if ( $action === 'list-delete' ) {
$id = (int) ( $_POST['id'] ?? 0 ); if ( $id <= 0 ) ml_fail( 'not_found' );
$db->where( 'entry_list', $id ); $db->delete( 'pacim_mail_list_entries' );
$db->where( 'list_id', $id ); $db->delete( 'pacim_mail_lists' );
ml_json( array( 'ok' => true ) );
}
if ( $action === 'list-entry-add' ) {
$lid = (int) ( $_POST['list_id'] ?? 0 );
if ( $lid <= 0 || ! $db->rawQueryOne( "SELECT list_id FROM pacim_mail_lists WHERE list_id=?", array( $lid ) ) ) ml_fail( 'Liste nicht gefunden.' );
$fn = trim( (string) ( $_POST['firstname'] ?? '' ) );
$ln = trim( (string) ( $_POST['lastname'] ?? '' ) );
$co = trim( (string) ( $_POST['company'] ?? '' ) );
// Batch: komma-/zeilen-/semikolongetrennte E-Mails
$raw = (string) ( $_POST['emails'] ?? ( $_POST['email'] ?? '' ) );
$parts = preg_split( '/[\s,;]+/', $raw );
$now = date( 'Y-m-d H:i:s' ); $added = 0; $skipped = 0;
$single = ( count( array_filter( array_map( 'trim', (array) $parts ) ) ) <= 1 );
foreach ( (array) $parts as $em ) {
$em = trim( $em ); if ( $em === '' ) continue;
if ( ! filter_var( $em, FILTER_VALIDATE_EMAIL ) ) { $skipped++; continue; }
if ( $db->rawQueryOne( "SELECT entry_id FROM pacim_mail_list_entries WHERE entry_list=? AND entry_email=?", array( $lid, $em ) ) ) { $skipped++; continue; }
// Name/Firma nur bei Einzeleintrag übernehmen (Batch = nur E-Mails)
$db->insert( 'pacim_mail_list_entries', array(
'entry_list' => $lid, 'entry_email' => $em,
'entry_firstname' => $single ? $fn : '', 'entry_lastname' => $single ? $ln : '', 'entry_company' => $single ? $co : '',
'entry_created' => $now,
) );
$added++;
}
ml_json( array( 'ok' => true, 'added' => $added, 'skipped' => $skipped ) );
}
if ( $action === 'list-entry-delete' ) {
$eid = (int) ( $_POST['id'] ?? 0 ); if ( $eid <= 0 ) ml_fail( 'not_found' );
$db->where( 'entry_id', $eid ); $db->delete( 'pacim_mail_list_entries' );
ml_json( array( 'ok' => true ) );
}
/* ============================ Newsletter-Kampagnen ============================ */
if ( $action === 'campaigns' ) {
$rows = $db->rawQuery( "SELECT campaign_id, campaign_name, campaign_subject, campaign_status, campaign_total, campaign_sent, campaign_failed, campaign_updated FROM pacim_newsletter_campaign ORDER BY campaign_id DESC" );
ml_json( array( 'ok' => true, 'campaigns' => $rows ? $rows : array(), 'fields' => newsletter::filterFields(), 'audiences' => newsletter::audiences(), 'lists' => newsletter::lists(), 'accounts' => mailservice::accountsList() ) );
}
if ( $action === 'campaign' ) {
$r = $db->rawQueryOne( "SELECT * FROM pacim_newsletter_campaign WHERE campaign_id = ?", array( (int) ( $_GET['id'] ?? 0 ) ) );
if ( ! $r ) ml_fail( 'not_found' );
$r['filters_arr'] = $r['campaign_filters'] ? json_decode( $r['campaign_filters'], true ) : array();
$r['attachments_arr'] = $r['campaign_attachments'] ? json_decode( $r['campaign_attachments'], true ) : array();
ml_json( array( 'ok' => true, 'campaign' => $r, 'fields' => newsletter::filterFields(), 'audiences' => newsletter::audiences(), 'lists' => newsletter::lists(), 'accounts' => mailservice::accountsList() ) );
}
if ( $action === 'campaign-save' ) {
$id = (int) ( $_POST['id'] ?? 0 );
$name = trim( (string) ( $_POST['name'] ?? '' ) );
if ( $name === '' ) ml_fail( 'Bitte einen Namen angeben.' );
$filters = array();
if ( isset( $_POST['filters'] ) && $_POST['filters'] !== '' ) { $d = json_decode( (string) $_POST['filters'], true ); if ( is_array( $d ) ) $filters = $d; }
$audience = (string) ( $_POST['audience'] ?? 'customer' );
if ( ! newsletter::audienceValid( $audience ) ) $audience = 'customer';
// Geplanter Versand (optional): datetime-local 'Y-m-dTH:i' -> 'Y-m-d H:i:s' oder NULL
$sched = trim( (string) ( $_POST['scheduled'] ?? '' ) );
$schedVal = null;
if ( $sched !== '' ) { $ts = strtotime( str_replace( 'T', ' ', $sched ) ); if ( $ts ) $schedVal = date( 'Y-m-d H:i:s', $ts ); }
// Datei-Anhänge (Liste {path,name}); nur erlaubte Upload-Pfade unter mailing/uploads/
$atts = array();
if ( isset( $_POST['attachments'] ) && $_POST['attachments'] !== '' ) {
$da = json_decode( (string) $_POST['attachments'], true );
if ( is_array( $da ) ) foreach ( $da as $a ) {
$p = isset( $a['path'] ) ? ltrim( (string) $a['path'], '/' ) : '';
if ( $p === '' || strpos( $p, '..' ) !== false || strpos( $p, 'mailing/uploads/' ) !== 0 ) continue;
$atts[] = array( 'path' => $p, 'name' => isset( $a['name'] ) && $a['name'] !== '' ? (string) $a['name'] : basename( $p ) );
}
}
$fields = array(
'campaign_name' => $name,
'campaign_subject' => trim( (string) ( $_POST['subject'] ?? '' ) ),
'campaign_audience'=> $audience,
'campaign_account' => max( 0, (int) ( $_POST['account'] ?? 0 ) ),
'campaign_scheduled' => $schedVal,
'campaign_attachments' => json_encode( $atts, JSON_UNESCAPED_UNICODE ),
'campaign_content' => (string) ( $_POST['content'] ?? '' ),
'campaign_logic' => ( ( $_POST['logic'] ?? 'and' ) === 'or' ) ? 'or' : 'and',
'campaign_filters' => json_encode( $filters, JSON_UNESCAPED_UNICODE ),
'campaign_updated' => date( 'Y-m-d H:i:s' ),
);
if ( ! $id ) {
$fields['campaign_status'] = 'draft'; $fields['campaign_created'] = date( 'Y-m-d H:i:s' );
$newId = $db->insert( 'pacim_newsletter_campaign', $fields );
if ( ! $newId ) ml_fail( 'Kampagne konnte nicht angelegt werden.' );
ml_json( array( 'ok' => true, 'id' => (int) $newId ) );
}
// Bearbeiten setzt eine bereits eingefrorene/gesendete Kampagne zurück auf draft (Empfänger neu einfrieren)
$db->where( 'campaign_id', $id );
$cur = $db->rawQueryOne( "SELECT campaign_status FROM pacim_newsletter_campaign WHERE campaign_id=?", array( $id ) );
if ( $cur && in_array( $cur['campaign_status'], array( 'ready' ), true ) ) $fields['campaign_status'] = 'draft';
$db->where( 'campaign_id', $id );
$db->update( 'pacim_newsletter_campaign', $fields );
ml_json( array( 'ok' => true, 'id' => $id ) );
}
if ( $action === 'campaign-delete' ) {
$id = (int) ( $_POST['id'] ?? 0 ); if ( $id <= 0 ) ml_fail( 'not_found' );
$db->where( 'recipient_campaign', $id ); $db->delete( 'pacim_newsletter_recipient' );
$db->where( 'campaign_id', $id ); $db->delete( 'pacim_newsletter_campaign' );
ml_json( array( 'ok' => true ) );
}
if ( $action === 'campaign-count' ) {
$filters = array(); if ( isset( $_POST['filters'] ) && $_POST['filters'] !== '' ) { $d = json_decode( (string) $_POST['filters'], true ); if ( is_array( $d ) ) $filters = $d; }
$logic = ( ( $_POST['logic'] ?? 'and' ) === 'or' ) ? 'or' : 'and';
$audience = (string) ( $_POST['audience'] ?? 'customer' );
if ( ! newsletter::audienceValid( $audience ) ) $audience = 'customer';
ml_json( array( 'ok' => true, 'count' => newsletter::count( $filters, $logic, $audience ) ) );
}
if ( $action === 'campaign-freeze' ) {
ml_json( array_merge( array( 'ok' => false ), newsletter::freeze( (int) ( $_POST['id'] ?? 0 ) ) ) );
}
if ( $action === 'campaign-send' ) {
$id = (int) ( $_POST['id'] ?? 0 );
$c = $db->rawQueryOne( "SELECT campaign_status, campaign_total FROM pacim_newsletter_campaign WHERE campaign_id=?", array( $id ) );
if ( ! $c ) ml_fail( 'not_found' );
if ( ! in_array( $c['campaign_status'], array( 'ready', 'paused' ), true ) ) ml_fail( 'Bitte zuerst Empfänger einfrieren.' );
if ( (int) $c['campaign_total'] <= 0 ) ml_fail( 'Keine Empfänger.' );
$db->where( 'campaign_id', $id );
$db->update( 'pacim_newsletter_campaign', array( 'campaign_status' => 'sending', 'campaign_updated' => date( 'Y-m-d H:i:s' ) ) );
ml_json( array( 'ok' => true, 'transport' => mailservice::config()['transport'] ) );
}
if ( $action === 'campaign-test' ) {
$to = trim( (string) ( $_POST['to'] ?? '' ) );
if ( ! filter_var( $to, FILTER_VALIDATE_EMAIL ) ) ml_fail( 'Bitte gültige Empfängeradresse angeben.' );
$tpl = (object) array( 'template_title' => (string) ( $_POST['subject'] ?? '' ), 'template_content' => (string) ( $_POST['content'] ?? '' ), 'template_layout' => 1, 'template_header' => '', 'template_footer' => '' );
$ctx = array( 'customer_firstname' => 'Max', 'customer_lastname' => 'Mustermann', 'name' => 'Max Mustermann', 'salutation' => 'Guten Tag Max Mustermann', 'link_unsubscribe' => SITE_URL . '/newsletter.php?a=unsub&c=0&t=test' );
$r = mailrenderer::render( $tpl, $ctx, true );
$files = newsletter::campaignFiles( array( 'campaign_attachments' => (string) ( $_POST['attachments'] ?? '' ) ) );
$topts = array( 'list_unsubscribe' => $ctx['link_unsubscribe'] );
if ( isset( $r['plain'] ) && trim( (string) $r['plain'] ) !== '' ) $topts['plain'] = (string) $r['plain']; // Text-Fassung (ohne Header)
if ( (int) ( $_POST['account'] ?? 0 ) > 0 ) $topts['account'] = (int) $_POST['account'];
$res = mailservice::send( $to, '', '[TEST] ' . $r['subject'], $r['html'], $files, $topts );
ml_json( array( 'ok' => $res['ok'], 'error' => isset( $res['error'] ) ? $res['error'] : null ) );
}
/* ============================ Versand-Queue ============================ */
if ( $action === 'queue' ) {
$rows = $db->rawQuery( "SELECT message_id, DATE_FORMAT(message_datetime,'%d.%m.%Y %H:%i') AS dt, message_receiver, message_subject, message_status, message_count, message_error, message_type
FROM pacim_messages WHERE message_type IN ('system','newsletter') AND message_status IN (5,6) ORDER BY message_id DESC LIMIT 200" );
$out = array();
foreach ( (array) $rows as $r ) {
$out[] = array( 'id' => (int) $r['message_id'], 'dt' => $r['dt'], 'to' => $r['message_receiver'], 'subject' => $r['message_subject'],
'status' => ( (int) $r['message_status'] === 6 ? 'failed' : 'queued' ), 'attempts' => (int) $r['message_count'], 'error' => $r['message_error'],
'kind' => ( $r['message_type'] === 'newsletter' ? 'newsletter' : 'system' ) );
}
ml_json( array( 'ok' => true, 'stats' => mailqueue::stats(), 'transport' => mailservice::config()['transport'], 'messages' => $out ) );
}
/* Letzte 50 versendete System-Mails (für die Versand-Ansicht). */
if ( $action === 'sent' ) {
$rows = $db->rawQuery( "SELECT message_id, DATE_FORMAT(message_datetime,'%d.%m.%Y %H:%i') AS dt, message_receiver, message_subject,
message_opened, message_invoice, message_type
FROM pacim_messages
WHERE message_type IN ('system','newsletter') AND message_status=" . mailqueue::ST_SENT . "
ORDER BY message_id DESC LIMIT 50" );
$out = array();
foreach ( (array) $rows as $r ) {
$out[] = array(
'id' => (int) $r['message_id'],
'dt' => $r['dt'],
'to' => $r['message_receiver'],
'subject' => $r['message_subject'],
'opened' => ( ! empty( $r['message_opened'] ) && $r['message_opened'] !== '0000-00-00 00:00:00' ),
'invoice' => (int) $r['message_invoice'],
'kind' => ( $r['message_type'] === 'newsletter' ? 'newsletter' : 'system' ),
);
}
ml_json( array( 'ok' => true, 'messages' => $out ) );
}
/* Inhalt einer versendeten Mail (read-only Vorschau). */
if ( $action === 'sent-view' ) {
$id = (int) ( $_GET['id'] ?? ( $_POST['id'] ?? 0 ) );
if ( $id <= 0 ) ml_fail( 'Ungültige ID.' );
$r = $db->rawQueryOne( "SELECT message_id, DATE_FORMAT(message_datetime,'%d.%m.%Y %H:%i') AS dt, message_receiver, message_subject, message_content
FROM pacim_messages WHERE message_id=? AND message_type IN ('system','newsletter') AND message_status=" . mailqueue::ST_SENT, array( $id ) );
if ( ! $r ) ml_fail( 'Mail nicht gefunden.' );
ml_json( array( 'ok' => true, 'id' => (int) $r['message_id'], 'dt' => $r['dt'], 'to' => $r['message_receiver'],
'subject' => $r['message_subject'], 'html' => (string) $r['message_content'] ) );
}
if ( $action === 'queue-process' ) {
ml_json( array_merge( array( 'ok' => true ), mailqueue::processQueue( 50 ) ) );
}
http_response_code( 400 );
ml_json( array( 'ok' => false, 'error' => 'unknown_action' ) );