mahnung/ajax/sendmail.php
Eduard Wisch 0244a5b07e feat(mahnung): Mailversand ueber FormMail, Versandprotokoll, HTML-Mails
Phase 13/14 abgeschlossen. Der Mailversand war zuvor KAPUTT: ajax/sendmail.php
war bereits zur Funktionsbibliothek umgebaut, card.php postete aber weiterhin
per JS dagegen.

Mailformular (FormMail)
- card.php nutzt jetzt Dolibarrs Standard-Mailformular (action=presend/send):
  Empfaenger (Firma + alle Ansprechpartner), Betreff, Text und Anhang sichtbar
  und aenderbar. Gesendet wird ausschliesslich ueber mahnungSendeErinnerungsMail().
- Anhang = unveraenderte Original-Rechnungs-PDF, wird bei Bedarf nacherzeugt.
  Eigener Parameter mailinit statt mode=init, weil get_form() bei mode=init die
  Anhangsliste selbst leert.
- HTML-Mails: DolEditor im Setup + withfckeditor=-1 im Formular (folgt
  FCKEDITOR_ENABLE_MAIL wie Dolibarrs eigene Mailvorlagen).
- Klartext bleibt Klartext: GETPOST('restricthtml') jagt jeden Nicht-HTML-Text
  durch dol_nl2br() — mahnungBodyEntkleiden() nimmt nur dieses Artefakt zurueck
  und laesst echte Formatierung unangetastet.
- Platzhalter jetzt auch in Dolibarr-Schreibweise (__REF__, __DATE_YMD__,
  __AMOUNT_FORMATED__, __DATE_DUE_YMD__, __FRIST_TAGE__ ...), Liste sichtbar im Setup.
- Absender-Adresse und -Name konfigurierbar (MAHNUNG_EMAIL_SENDER[_NAME]).
- Erneuter Versand moeglich (force aus dem Status abgeleitet, nicht aus dem
  Request — Doppelversand-Schutz bleibt wirksam).

Versandprotokoll (neue Tabelle llx_mahnung_mailprotokoll)
- Jede versendete Erinnerung wird mit Empfaenger, Kopie, Betreff, Text und
  Anhangsnamen festgehalten, einsehbar unter Versandstatus. Historie statt
  Spalten am Vorgang, weil erneut gesendet werden kann.
- Lazy-Migration legt die Tabelle an (DB_VERSION 0.4.0), kein Reaktivieren noetig.

Haertung nach Code-Review (21 bestaetigte Funde)
- Anhang liess sich nicht abwaehlen (wurde sofort wieder eingehaengt)
- Upload/Entfernen ohne Rechtepruefung; Temp-Verzeichnis pro Vorgang getrennt
- Teilzahlung zwischen Oeffnen und Senden fuehrt zurueck ins Formular
- Empfaenger: Semikolon-Trenner, keine stillen Verwerfungen, Dubletten, CR/LF
- CSRF: presend + Core-Dateiaktionen (confirm_deletefile, renamefile, sendit,
  linkit) token-pflichtig
- Externe Benutzer sehen nur eigene Vorgaenge; Abschreiben verlangt facture.creer

UI
- Mahnstufe nur noch EINE Darstellung (Badge), Farbskala zentral in
  lib/mahnung_ui.lib.php statt doppelt gepflegt
- Zahnrad oben rechts in die Einstellungen (nur mit Recht mahnung.setup)
- Original-Rechnung unter Verknuepfte Dokumente mit Vorschau, Groesse in KB
- PDF-Einleitungstext nur noch, wo ueberhaupt ein PDF entsteht

Sprachdateien de_DE/en_US deckungsgleich, 9 tote Keys entfernt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 21:31:25 +02:00

1215 lines
49 KiB
PHP

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* GPL v3 (siehe COPYING).
*/
/**
* \file htdocs/custom/mahnung/ajax/sendmail.php
* \ingroup mahnung
* \brief Funktionsbibliothek für den Versand der kostenlosen Zahlungserinnerung.
*
* ACHTUNG — diese Datei ist KEIN eigenständiger Endpoint mehr. Sie
* stellt nur noch die gehärteten Bausteine bereit, die card.php
* benutzt: Empfängerermittlung, Rechnungsprüfung, Anhang-Suche,
* Platzhalter, Statuswächter, atomare Versand-Reservierung und den
* eigentlichen CMailFile-Aufruf.
*
* Warum kein eigener AJAX-Endpoint mehr?
* Der Versand läuft jetzt über das Dolibarr-Standard-Mailformular
* (FormMail) auf der Mahnungs-Karte: card.php?id=…&action=presend.
* Dort sind Empfänger (inkl. der Ansprechpartner des Kunden), Betreff,
* Text und Anhang sichtbar UND änderbar. Gäbe es daneben weiter einen
* Endpoint, der eigenständig sendet, existierten zwei Sendewege, die
* auseinanderlaufen können. Es gibt deshalb bewusst nur EINEN:
* mahnungSendeErinnerungsMail(). Ein Direktaufruf dieser Datei per HTTP
* verschickt nichts, sondern leitet auf das Formular um.
*
* Fachliche Sperre (unverändert): versendet werden darf ausschließlich
* eine Stufe mit llx_mahnung_stufe.ist_erinnerung = 1. Echte Mahnungen
* gehen per Post/Einschreiben raus — eine E-Mail ist nicht beweisbar
* zugestellt.
*
* Angehängt wird die UNVERÄNDERTE Original-Rechnungs-PDF. Es wird kein
* Mahn-PDF erzeugt, pdf_path des Mahnvorgangs bleibt unangetastet.
* Fehlt die Rechnungs-PDF auf der Platte, wird sie regulär über
* Facture::generateDocument() nacherzeugt — dafür ist zusätzlich das
* Recht facture.creer nötig.
*
* Doppelversand-Schutz: der Vorgang wird VOR dem Senden per bedingtem
* UPDATE auf VERSENDET reserviert (siehe mahnungReserviereVersand()).
* Nur wer die Zeile tatsächlich trifft, darf senden; schlägt der Versand
* fehl, wird die Reservierung wieder zurückgenommen.
*
* Die neue Zahlungsfrist im Mailtext ({frist}) wird zum Versandzeitpunkt
* aus neue_frist_tage der Stufe neu berechnet und mitgespeichert — sonst
* nennt eine später verschickte Erinnerung eine verstrichene Frist.
*
* Ebenso wird der offene Betrag ({summe}) zum Versandzeitpunkt frisch
* an der Rechnung geprüft: ist sie inzwischen bezahlt, storniert oder
* abgeschrieben, wird der Versand abgelehnt; bei einer Teilzahlung
* werden betrag_offen/summe_mahnung nachgezogen und mitgespeichert.
*/
// ---------------------------------------------------------------------------
// Direktaufruf per HTTP (Altlast)
// ---------------------------------------------------------------------------
// Wird die Datei von card.php eingebunden, lief main.inc.php längst und
// DOL_DOCUMENT_ROOT ist definiert — dann steht unterhalb nur Funktionsdefinition
// und es passiert beim Einbinden nichts.
// Ruft dagegen jemand ajax/sendmail.php direkt auf (altes Lesezeichen, eine noch
// im Browser stehende Seite von vor dem Umbau), landet er hier: es wird nichts
// verschickt, sondern auf das Mailformular der Karte weitergeleitet.
if (!defined('DOL_DOCUMENT_ROOT')) {
if (!defined('NOREQUIREMENU')) {
define('NOREQUIREMENU', '1');
}
if (!defined('NOTOKENRENEWAL')) {
define('NOTOKENRENEWAL', '1');
}
require_once $_SERVER['DOCUMENT_ROOT'].'/main.inc.php';
$langs->loadLangs(array('mahnung@mahnung'));
$mahnungIdAlt = GETPOSTINT('mahnung_id');
$zielAlt = DOL_URL_ROOT.'/custom/mahnung/list.php?mainmenu=billing&leftmenu=mahnung';
if ($mahnungIdAlt > 0) {
// mailinit=1, NICHT mode=init: FormMail::get_form() leert bei mode=init selbst
// die Anhangsliste und würde die von card.php eingehängte Rechnungs-PDF gleich
// wieder entfernen — das Formular käme ohne Anhang hoch.
// Token mitgeben: 'presend' ist token-pflichtig, weil dabei die Rechnungs-PDF
// bereitgestellt (und notfalls erzeugt) wird.
$zielAlt = DOL_URL_ROOT.'/custom/mahnung/card.php?id='.$mahnungIdAlt.'&action=presend&mailinit=1&token='.newToken().'#formmail';
}
// Der alte Aufruf war ein jQuery.post() mit dataType "json". Damit eine noch
// offene alte Seite keinen kryptischen Parse-Fehler zeigt, wird dort sauber
// mit JSON geantwortet statt mit einem Redirect auf HTML.
$willJson = false;
if (!empty($_SERVER['HTTP_ACCEPT']) && stripos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) {
$willJson = true;
}
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
$willJson = true;
}
if ($willJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(array(
'success' => false,
'redirect' => $zielAlt,
'message' => $langs->transnoentities('MahnungMailEndpointEntfallen'),
));
exit;
}
header('Location: '.$zielAlt);
exit;
}
require_once DOL_DOCUMENT_ROOT.'/core/class/CMailFile.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnung.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/mahnung/class/mahnungstufe.class.php';
// ---------------------------------------------------------------------------
// Empfänger
// ---------------------------------------------------------------------------
/**
* Auswahlliste der möglichen Empfänger eines Kunden.
*
* Liefert exakt das, was Dolibarr im Standard-Mailformular anbietet: die
* Haupt-E-Mail der Firma UND die E-Mail-Adressen aller aktiven Ansprechpartner
* (llx_socpeople). Genau das braucht Eddy — viele Kunden haben eine
* Firmenadresse und zusätzlich einen Kontakt, an den die Rechnung gehen soll.
*
* Format: array(<socpeople.rowid>|'thirdparty' => "Name - Position <mail@x>").
* Der SCHLÜSSEL transportiert die Identität, das Label ist nur Anzeige — so
* erwartet es FormMail::getHtmlForTo().
*
* @param Societe $societe Geladener Kunde
* @return array<int|string,string>
*/
function mahnungEmpfaengerListe($societe)
{
if (!is_object($societe) || empty($societe->id)) {
return array();
}
$liste = array();
// thirdparty_and_contact_email_array(1) = aktive Kontakte + Firmenadresse.
foreach ($societe->thirdparty_and_contact_email_array(1) as $key => $label) {
$liste[$key] = (string) $label;
}
return $liste;
}
/**
* Zerlegt einen Empfängereintrag "Name <mail@x>" (oder "mail@x") und prüft ihn.
*
* Der Name wird von Zeichen befreit, die eine Adressliste bzw. einen Mail-Header
* zerlegen könnten (Komma, Semikolon, spitze Klammern, Anführungszeichen,
* Steuerzeichen). Ohne gültige Mailadresse gibt es null — Kontakte ohne Mail
* tragen im Label den übersetzten Text "NoEMail" und fallen hier heraus.
*
* @param string $eintrag Roher Eintrag
* @return array{mail:string,voll:string}|null
*/
function mahnungAdresseNormieren($eintrag)
{
$eintrag = mahnungHeaderEinzeilig($eintrag);
if ($eintrag === '') {
return null;
}
$reg = array();
if (preg_match('/^(.*)<([^<>]+)>[^<>]*$/', $eintrag, $reg)) {
$name = trim(dol_string_nospecial($reg[1], ' ', array(',', ';', '<', '>', '"')));
$mail = trim($reg[2]);
} else {
$name = '';
$mail = trim($eintrag);
}
if ($mail === '' || !isValidEmail($mail)) {
return null;
}
return array(
// Kleinschreibung dient nur als Dubletten-Schlüssel — verschickt wird 'voll'.
'mail' => strtolower($mail),
'voll' => ($name !== '' ? $name.' <'.$mail.'>' : $mail),
);
}
/**
* Zerlegt ein Freitext-Adressfeld ("a@x, Name <b@y>") in geprüfte Adressen.
*
* Genutzt für die frei eintragbaren Felder des Mailformulars (An, Kopie,
* Blindkopie). Getrennt wird an Komma UND Semikolon: Dolibarr nennt zwar nur das
* Komma, aus Outlook ist das Semikolon aber so geläufig, dass "a@x; b@y" sonst
* als eine einzige — ungültige — Adresse durchliefe.
*
* Unbrauchbare Teile werden NICHT still geschluckt, sondern in $verworfen
* zurückgegeben. Ein Tippfehler in einer Adresse darf nicht dazu führen, dass die
* Mail klaglos an weniger Empfänger geht, als im Formular standen.
*
* Der Rückgabe-Schlüssel ist die kleingeschriebene Adresse, wodurch Dubletten
* (auch über mehrere Aufrufe hinweg) von selbst zusammenfallen.
*
* @param string $eingabe Roher Feldinhalt
* @param array $verworfen Rückgabe: nicht verwertbare Teile
* @return array<string,string> Mailadresse (klein) => "Name <mail@x>"
*/
function mahnungAdressenAusFreitext($eingabe, &$verworfen = array())
{
$treffer = array();
$verworfen = array();
foreach (preg_split('/[,;]/', (string) $eingabe) as $teil) {
if (trim((string) $teil) === '') {
continue;
}
$norm = mahnungAdresseNormieren($teil);
if ($norm === null) {
$verworfen[] = trim((string) $teil);
continue;
}
$treffer[$norm['mail']] = $norm['voll'];
}
return $treffer;
}
/**
* Sendefertige Adresse zu einem Schlüssel aus mahnungEmpfaengerListe().
*
* Wichtig für die Sicherheit: der Aufrufer MUSS vorher prüfen, dass der
* Schlüssel wirklich aus der Liste dieses Kunden stammt. Societe::
* contact_get_property() fragt llx_socpeople allein über die rowid ab und
* validiert die Firmenzugehörigkeit NICHT — ein manipuliertes receiver[] könnte
* sonst die Erinnerung an einen beliebigen fremden Kontakt schicken.
*
* @param Societe $societe Geladener Kunde
* @param int|string $key 'thirdparty' oder socpeople.rowid
* @return string "Name <mail@x>" oder '' wenn unbrauchbar
*/
function mahnungEmpfaengerAdresse($societe, $key)
{
$key = (string) $key;
if ($key === 'thirdparty') {
$mail = trim((string) $societe->email);
if ($mail === '' || !isValidEmail($mail)) {
return '';
}
// mahnungHeaderEinzeilig() zuerst: dol_string_nospecial() entfernt zwar die
// aufgezählten Zeichen, lässt CR/LF/TAB aber stehen — ein Firmenname mit
// Zeilenumbruch (aus einem Import) könnte damit einen Mail-Header aufspalten.
$name = trim(dol_string_nospecial(mahnungHeaderEinzeilig((string) $societe->name), ' ', array(',', ';', '<', '>', '"')));
return ($name !== '' ? $name.' <'.$mail.'>' : $mail);
}
if ((int) $key <= 0) {
return '';
}
$norm = mahnungAdresseNormieren((string) $societe->contact_get_property((int) $key, 'email'));
return ($norm === null) ? '' : $norm['voll'];
}
// ---------------------------------------------------------------------------
// Rechnung: ist überhaupt noch etwas zu mahnen?
// ---------------------------------------------------------------------------
/**
* Prüft die Rechnung zum Versandzeitpunkt und zieht den offenen Betrag nach.
*
* Zwischen der Anlage des Mahnvorgangs (Vorschlagsliste) und dem Versand liegt
* bauartbedingt eine manuelle Bestätigung — also beliebig viel Zeit. In dieser
* Zeit kann die Rechnung bezahlt, TEILbezahlt, storniert oder als uneinbringlich
* abgeschrieben worden sein. Der Statuswächter am Mahnvorgang sieht davon
* nichts: der Zahlungs-Trigger erledigt Mahnvorgänge nur bei VOLLzahlung
* (istRechnungVollBezahlt()), und auf BILL_CANCEL hört das Modul gar nicht.
* Deshalb wird der aktuelle Stand hier am Original nachgeprüft.
*
* Ändert sich der offene Betrag, werden betrag_offen und summe_mahnung am
* übergebenen Objekt nachgezogen (in-memory). Gespeichert wird das erst mit der
* Versand-Reservierung, damit Mail und Datensatz denselben Betrag nennen.
*
* @param Mahnung $mahnung Geladener Mahnvorgang (wird ggf. angepasst)
* @param Facture $facture Geladene Rechnung
* @param string $fehler Rückgabe: Grund der Ablehnung
* @param bool $betragAngepasst Rückgabe: true = Betrag wurde nachgezogen
* @return bool true = es ist noch etwas offen
*/
function mahnungRechnungNochOffen($mahnung, $facture, &$fehler, &$betragAngepasst)
{
global $langs;
$fehler = '';
$betragAngepasst = false;
// $facture->status ist der aktuelle Feldname, $facture->statut der alte —
// beide werden von Facture::fetch() befüllt, der Fallback hält ältere
// Dolibarr-Versionen offen.
$statutRechnung = (int) (isset($facture->status) ? $facture->status : $facture->statut);
if (!empty($facture->paye) || $statutRechnung !== Facture::STATUS_VALIDATED) {
$fehler = $langs->trans('MahnungMailRechnungNichtMehrOffen', $facture->ref);
return false;
}
$betragOffenAktuell = mahnungBetragOffenAktuell($facture);
if ($betragOffenAktuell === null) {
// Konnte nicht sicher gerechnet werden — lieber nichts verschicken als einen
// falschen Betrag zu mahnen.
$fehler = $langs->trans('MahnungMailBetragNichtErmittelbar', $facture->ref);
return false;
}
// Schwelle 0,005 EUR: Rundungsreste unterhalb eines halben Cents sind keine
// offene Forderung mehr (gleiche Toleranz wie im Zahlungs-Trigger).
if ($betragOffenAktuell <= 0.005) {
$fehler = $langs->trans('MahnungMailRechnungBereitsBezahlt', $facture->ref);
return false;
}
// Teilzahlung o.Ä.: der Vorgang trägt sonst den Stand vom Anlagezeitpunkt in die
// Mail ({summe}). Betrag nachziehen, bevor die Platzhalter gebaut werden.
// Bei einer Zahlungserinnerung sind Mahngebühr, §288-Pauschale, Verzugszinsen
// und Vorstufenkosten hart 0 (siehe mahnungBaueVorgang()) — rechneSumme() lässt
// die Erinnerung damit kostenfrei, summe_mahnung folgt exakt dem offenen Betrag.
$betragOffenVorher = (float) $mahnung->betrag_offen;
if (abs($betragOffenAktuell - $betragOffenVorher) >= 0.005) {
dol_syslog('mahnung/sendmail: offener Betrag zu '.$facture->ref.' hat sich von '
.price2num($betragOffenVorher, 'MT').' auf '.price2num($betragOffenAktuell, 'MT')
.' geaendert — Mahnvorgang wird mitgezogen', LOG_INFO);
$mahnung->betrag_offen = $betragOffenAktuell;
$mahnung->rechneSumme();
$betragAngepasst = true;
}
return true;
}
/**
* Aktuell offener Betrag einer Rechnung.
*
* Gerechnet wird wie im Rechnungsmodul selbst (compta/facture/card.php):
* total_ttc - Zahlungen - verrechnete Anzahlungen - verrechnete Gutschriften
* Die reine Differenz aus total_ttc und llx_paiement_facture würde eine per
* Gutschrift oder Anzahlung ausgeglichene Rechnung fälschlich als offen
* ausweisen — genau das würde hier eine Zahlungserinnerung auslösen.
*
* Alle drei Dolibarr-Methoden liefern bei einem SQL-Fehler -1 und setzen
* $facture->error. Ein stumm mitverrechnetes -1 würde den offenen Betrag
* verfälschen (Vorzeichen: Betrag würde um 1 EUR steigen), deshalb wird der
* Fehlerfall sauber als "nicht ermittelbar" gemeldet statt gerechnet.
*
* @param Facture $facture Geladene Rechnung
* @return float|null Offener Betrag in EUR, null = nicht ermittelbar
*/
function mahnungBetragOffenAktuell($facture)
{
$summen = array();
foreach (array('getSommePaiement', 'getSumDepositsUsed', 'getSumCreditNotesUsed') as $methode) {
$facture->error = '';
$wert = (float) $facture->$methode();
if ($wert < 0 && $facture->error !== '') {
dol_syslog('mahnung/sendmail: '.$methode.'() zu Rechnung '.((int) $facture->id).' fehlgeschlagen: '.$facture->error, LOG_ERR);
return null;
}
$summen[] = $wert;
}
return round(((float) $facture->total_ttc) - array_sum($summen), 2);
}
// ---------------------------------------------------------------------------
// Anhang (Original-Rechnungs-PDF)
// ---------------------------------------------------------------------------
/**
* Liefert die Original-Rechnungs-PDF und erzeugt sie bei Bedarf nach.
*
* Bewusst die unveränderte Rechnung: sie weist den vollen Rechnungsbetrag aus,
* der noch offene Rest steht im Mailtext ({summe}). Die Datei wird nicht
* verändert und nicht in pdf_path geschrieben — eine Zahlungserinnerung hat
* kein eigenes Mahn-PDF.
*
* @param Facture $facture Geladene Rechnung
* @param User $user Aufrufender Benutzer (Rechteprüfung)
* @param string $fehler Rückgabe: Fehlertext ('' = alles gut)
* @return string Absoluter Pfad oder ''
*/
function mahnungHoleRechnungsPdf($facture, $user, &$fehler)
{
global $langs;
$fehler = '';
$pdfDatei = mahnungFindeRechnungsPdf($facture);
if ($pdfDatei !== '') {
return $pdfDatei;
}
// Regulär über das Dokumentenmodell des Rechnungsmoduls nacherzeugen.
//
// generateDocument() schreibt in das Dokumentenverzeichnis des RECHNUNGS-
// moduls. Das Recht mahnung.send allein reicht dafür nicht — wer keine
// Rechnungen anlegen/ändern darf, darf hier auch keine Rechnungs-PDF
// erzeugen lassen.
if (!$user->hasRight('facture', 'creer')) {
$fehler = $langs->trans('MahnungMailRechnungsPdfKeinRecht', $facture->ref);
return '';
}
dol_syslog('mahnung/sendmail: Rechnungs-PDF zu '.$facture->ref.' fehlt — wird nacherzeugt', LOG_INFO);
if ($facture->generateDocument('', $langs) > 0) {
$pdfDatei = mahnungFindeRechnungsPdf($facture);
}
if ($pdfDatei === '') {
$fehler = $langs->trans('MahnungMailRechnungsPdfFehlt', $facture->ref);
}
return $pdfDatei;
}
/**
* Ermittelt den absoluten Pfad der ORIGINAL-Rechnungs-PDF.
*
* Reihenfolge:
* 1. $facture->last_main_doc (von Dolibarr gepflegter Zeiger, relativ zu
* DOL_DATA_ROOT)
* 2. Standardpfad <output>/<REF>/<REF>.pdf
*
* Die Referenz läuft durch dol_sanitizeFileName() (entfernt u.a. "/", "\" und
* ".."), zusätzlich wird über realpath() geprüft, dass die gefundene Datei
* wirklich unterhalb des Rechnungs-Ausgabeverzeichnisses liegt. Damit ist ein
* Ausbrechen aus dem Verzeichnis (Path Traversal) ausgeschlossen.
*
* @param Facture $facture Geladene Rechnung
* @return string Absoluter Pfad oder '' wenn keine PDF vorhanden
*/
function mahnungFindeRechnungsPdf($facture)
{
$baseDir = mahnungRechnungsAusgabeVerzeichnis($facture);
if ($baseDir === '') {
return '';
}
$refSan = dol_sanitizeFileName((string) $facture->ref);
if ($refSan === '') {
return '';
}
$kandidaten = array();
// 1) Hauptdokument laut Dolibarr. ACHTUNG: last_main_doc ist relativ zu
// DOL_DATA_ROOT (z.B. "facture/FA2601-0001/FA2601-0001.pdf"), NICHT zum
// Ausgabeverzeichnis des Rechnungsmoduls — siehe commonGenerateDocument().
if (!empty($facture->last_main_doc)) {
$rel = ltrim(str_replace('\\', '/', (string) $facture->last_main_doc), '/');
if ($rel !== '' && strpos($rel, '..') === false && preg_match('/\.pdf$/i', $rel)) {
$kandidaten[] = rtrim(DOL_DATA_ROOT, '/').'/'.$rel;
}
}
// 2) Standardpfad des Rechnungsmoduls
$kandidaten[] = $baseDir.'/'.$refSan.'/'.$refSan.'.pdf';
// Beide Kandidaten müssen unterhalb des Rechnungs-Ausgabeverzeichnisses
// liegen — damit kann auch ein manipulierter last_main_doc nichts anderes
// als eine Rechnungs-PDF anhängen.
foreach ($kandidaten as $pfad) {
if (mahnungIstNutzbarePdf($pfad, $baseDir)) {
return $pfad;
}
}
return '';
}
/**
* Ausgabeverzeichnis des Rechnungsmoduls für die Entity der Rechnung.
* $conf->facture ist der Altname, $conf->invoice zeigt auf dasselbe Objekt.
*
* @param Facture $facture Geladene Rechnung
* @return string Verzeichnis ohne Slash am Ende oder ''
*/
function mahnungRechnungsAusgabeVerzeichnis($facture)
{
global $conf;
$entity = !empty($facture->entity) ? (int) $facture->entity : (int) $conf->entity;
if (!empty($conf->facture->multidir_output[$entity])) {
return rtrim($conf->facture->multidir_output[$entity], '/');
}
if (!empty($conf->invoice->multidir_output[$entity])) {
return rtrim($conf->invoice->multidir_output[$entity], '/');
}
if (!empty($conf->facture->dir_output)) {
return rtrim($conf->facture->dir_output, '/');
}
if (!empty($conf->invoice->dir_output)) {
return rtrim($conf->invoice->dir_output, '/');
}
return '';
}
/**
* Prüft, ob eine Datei als Mail-Anhang taugt: existiert, lesbar, nicht leer
* und nachweislich unterhalb des erlaubten Basisverzeichnisses.
*
* @param string $pfad Zu prüfender absoluter Pfad
* @param string $baseDir Erlaubtes Basisverzeichnis
* @return bool
*/
function mahnungIstNutzbarePdf($pfad, $baseDir)
{
if (!is_file($pfad) || !is_readable($pfad)) {
return false;
}
if ((int) filesize($pfad) <= 0) {
return false;
}
$real = realpath($pfad);
$realBase = realpath($baseDir);
if ($real === false || $realBase === false) {
return false;
}
return strpos($real, rtrim($realBase, '/').'/') === 0;
}
// ---------------------------------------------------------------------------
// Betreff / Text
// ---------------------------------------------------------------------------
/**
* Zahlungsfrist zum Versandzeitpunkt.
*
* date_lim_reglement_neu wurde bei der ANLAGE gesetzt (Anlagedatum +
* neue_frist_tage). Zwischen Anlage und Versand können Tage liegen — dann stünde
* in der Mail eine bereits verstrichene Frist. Deshalb hier aus neue_frist_tage
* der Stufe neu rechnen; gespeichert wird der Wert zusammen mit der
* Versand-Reservierung, damit Mail und Datensatz dieselbe Frist nennen.
*
* @param MahnungStufe $stufeObj Geladene Stufenkonfiguration
* @param int $jetzt Versandzeitpunkt (Unix-Zeit)
* @return int Unix-Zeit der neuen Frist
*/
function mahnungBerechneNeueFrist($stufeObj, $jetzt)
{
return dol_time_plus_duree($jetzt, (int) $stufeObj->neue_frist_tage, 'd');
}
/**
* Wertetabelle für die Platzhalter des Mailtextes.
*
* Unterstützt werden ZWEI Schreibweisen für dieselben Werte:
* - die modul-eigene mit geschweiften Klammern: {rechnung}
* - die Dolibarr-übliche mit Unterstrichen: __REF__
* Wer aus Dolibarr kommt, tippt selbstverständlich __REF__ — steht das Modul nur
* auf {…}, bleibt die Marke unersetzt im Betreff stehen. Beide Formen kosten
* nichts und ersparen das Nachschlagen.
*
* Zu __REF__: gemeint ist die RECHNUNGSnummer, nicht die des Mahnvorgangs. In
* einer Zahlungserinnerung spricht der Text immer von der Rechnung; die interne
* Mahnungs-Referenz sagt dem Kunden nichts. Wer sie doch braucht, nimmt
* __MAHNUNGREF__.
*
* @param Mahnung $mahnung Mahnvorgang
* @param ?Facture $facture Zugehörige Rechnung
* @param ?Societe $societe Kunde
* @param int $neueFrist Neu berechnete Zahlungsfrist (Unix-Zeit)
* @param ?MahnungStufe $stufeObj Stufenkonfiguration (für die Anzahl Fristtage)
* @return array<string,string>
*/
function mahnungPlatzhalterWerte($mahnung, $facture, $societe, $neueFrist, $stufeObj = null)
{
global $mysoc;
$refMahnung = (string) $mahnung->ref;
$refRechnung = (string) (is_object($facture) ? $facture->ref : '');
$stufe = (string) ((int) $mahnung->stufe);
$summe = price((float) $mahnung->summe_mahnung).' EUR';
$frist = $neueFrist ? dol_print_date($neueFrist, 'day') : '';
$kunde = (string) (is_object($societe) ? $societe->name : '');
$faellig = !empty($mahnung->date_lim_reglement_alt) ? dol_print_date($mahnung->date_lim_reglement_alt, 'day') : '';
$firma = (string) (isset($mysoc->name) ? $mysoc->name : '');
// Rechnungsdatum und -fälligkeit direkt aus der Rechnung; fällt sie weg, greifen
// die im Mahnvorgang gespeicherten Werte.
$rechnungsDatum = '';
if (is_object($facture) && !empty($facture->date)) {
$rechnungsDatum = dol_print_date($facture->date, 'day');
}
$faelligRechnung = '';
if (is_object($facture) && !empty($facture->date_lim_reglement)) {
$faelligRechnung = dol_print_date($facture->date_lim_reglement, 'day');
}
if ($faelligRechnung === '') {
$faelligRechnung = $faellig;
}
// Gesamtbetrag der Rechnung — bewusst NICHT der offene Betrag: __AMOUNT__ meint in
// Dolibarr den Rechnungsbetrag ("Rechnung über X"). Der noch offene Rest steht in
// {summe} / __SUMME__.
$rechnungsBetrag = is_object($facture) ? price((float) $facture->total_ttc).' EUR' : '';
// Anzahl Tage der neuen Zahlungsfrist — direkt aus der Stufen-Konfiguration
// (neue_frist_tage). Damit nennt der Text dieselbe Frist, die auch am Vorgang
// gespeichert wird; eine im Text fest eingetippte Zahl liefe sonst auseinander,
// sobald jemand die Stufe umkonfiguriert.
$fristTage = is_object($stufeObj) ? (string) ((int) $stufeObj->neue_frist_tage) : '';
return array(
// Modul-eigene Schreibweise
'{ref}' => $refMahnung,
'{stufe}' => $stufe,
'{summe}' => $summe,
'{rechnung}' => $refRechnung,
'{frist}' => $frist,
'{kunde}' => $kunde,
'{faellig}' => $faellig,
'{firma}' => $firma,
'{fristtage}' => $fristTage,
'{rechnungsbetrag}' => $rechnungsBetrag,
'{rechnungsdatum}' => $rechnungsDatum,
// Dolibarr-Schreibweise
'__REF__' => $refRechnung,
'__RECHNUNG__' => $refRechnung,
'__FACREF__' => $refRechnung,
'__MAHNUNGREF__' => $refMahnung,
'__STUFE__' => $stufe,
'__SUMME__' => $summe,
'__FRIST__' => $frist,
'__FRIST_TAGE__' => $fristTage,
'__NACHFRIST__' => $frist,
'__NACHFRIST_TAGE__' => $fristTage,
'__FAELLIG__' => $faellig,
'__KUNDE__' => $kunde,
'__THIRDPARTY_NAME__' => $kunde,
'__FIRMA__' => $firma,
'__MYCOMPANY_NAME__' => $firma,
// Rechnungsdaten in Dolibarr-Benennung. __AMOUNT__ ist dort der Betrag DER
// RECHNUNG — deshalb hier total_ttc und nicht der offene Rest.
// "FORMATED" mit einem T ist Dolibarrs eigene Schreibweise; die naheliegende
// Variante mit zwei T wird zusätzlich akzeptiert, damit ein Vertipper nicht
// als roher Platzhalter beim Kunden landet.
'__DATE_YMD__' => $rechnungsDatum,
'__DATE_DUE_YMD__' => $faelligRechnung,
'__AMOUNT__' => $rechnungsBetrag,
'__AMOUNT_FORMATED__' => $rechnungsBetrag,
'__AMOUNT_FORMATTED__' => $rechnungsBetrag,
'__TOTAL_TTC__' => $rechnungsBetrag,
);
}
/**
* Betreff und Text aus der Stufen-Konfiguration, Platzhalter bereits ersetzt.
*
* Der Benutzer soll im Formular den FERTIGEN Text sehen, keine {…}-Marken.
*
* @param MahnungStufe $stufeObj Geladene Stufenkonfiguration
* @param array<string,string> $werte Platzhalter aus mahnungPlatzhalterWerte()
* @return array{subject:string,body:string,ishtml:int}
*/
function mahnungMailVorlage($stufeObj, $werte)
{
global $langs;
// transnoentities() statt trans(): trans() encodiert Umlaute zu HTML-Entities
// ("Grüßen" -> "Gr&uuml;&szlig;en"), was in einer Klartext-Mail sichtbar wäre.
$rawSubject = (string) ($stufeObj->email_subject !== null && $stufeObj->email_subject !== ''
? $stufeObj->email_subject
: $langs->transnoentities('MahnungErinnerungMailBetreff'));
$rawBody = (string) ($stufeObj->email_body !== null && $stufeObj->email_body !== ''
? $stufeObj->email_body
: $langs->transnoentities('MahnungErinnerungMailText'));
// HTML-Modus aus dem ROH-Template ableiten — NICHT aus dem fertigen Text.
// Sonst würde ein Kundenname wie "Muster <GmbH>" den Modus umschalten.
$isHtml = dol_textishtml($rawBody) ? 1 : 0;
$subject = mahnungHeaderEinzeilig(mahnungErsetzePlatzhalter($rawSubject, $werte, 0));
if ($subject === '') {
// Leerer Betreff landet zuverlässig im Spam — auf die Standardvorlage zurückfallen.
$subject = mahnungHeaderEinzeilig(mahnungErsetzePlatzhalter($langs->transnoentities('MahnungErinnerungMailBetreff'), $werte, 0));
}
return array(
'subject' => $subject,
'body' => mahnungErsetzePlatzhalter($rawBody, $werte, $isHtml),
'ishtml' => $isHtml,
);
}
/**
* Ersetzt die {…}-Platzhalter in Betreff oder Text.
*
* Im HTML-Modus werden die eingesetzten Werte escaped — {kunde} und {rechnung}
* kommen aus der Datenbank und dürfen im HTML-Body keine Tags aufmachen.
*
* Wird zweimal angewandt: beim Vorbelegen des Formulars und noch einmal auf den
* abgeschickten Text. Das ist unschädlich (bereits ersetzte Marken sind weg) und
* löst Platzhalter auf, die der Benutzer im Formular selbst ergänzt hat.
*
* @param string $text Roher Text mit Platzhaltern
* @param array<string,string> $werte Platzhalter => Wert
* @param int $isHtml 1 = Werte HTML-escapen
* @return string
*/
function mahnungErsetzePlatzhalter($text, $werte, $isHtml)
{
return strtr((string) $text, $isHtml ? array_map('dol_escape_htmltag', $werte) : $werte);
}
/**
* Nimmt die Formular-Rundreise wieder aus einem KLARTEXT-Mailtext heraus.
*
* GETPOST(..., 'restricthtml') jagt jeden NICHT-HTML-Text durch dol_nl2br()
* (functions.lib.php, dol_htmlwithnojs) — allein durch das Abschicken des
* Formulars wird aus einem Klartext-Mailtext also HTML mit <br>-Tags. Das schlägt
* doppelt durch: im Textfeld stehen sichtbar die Tags, und dol_textishtml() stuft
* den Text danach als HTML ein, sodass eine als Klartext gepflegte Erinnerung als
* HTML-Mail rausginge.
*
* Zurückgebaut wird deshalb NUR dieses Artefakt: ein Text, der außer <br> keine
* Tags enthält, und auch nur dann, wenn die Stufen-Vorlage Klartext ist. Sobald
* echte Formatierung im Spiel ist — der Text wurde im WYSIWYG-Editor bearbeitet
* oder die Vorlage ist von vornherein HTML — bleibt alles unangetastet, sonst
* würde diese Funktion Fettschrift, Listen und Links wieder wegwerfen.
*
* @param string $text Text aus dem Formular
* @param int $vorlageIstHtml 1 = die Stufen-Vorlage ist HTML
* @return string
*/
function mahnungBodyEntkleiden($text, $vorlageIstHtml)
{
$text = (string) $text;
if (!empty($vorlageIstHtml) || !dol_textishtml($text)) {
return $text;
}
// Enthält der Text außer <br> noch andere Tags, ist es echte Formatierung.
$ohneBr = preg_replace('/<br\s*\/?>/i', '', $text);
if (strip_tags($ohneBr) !== $ohneBr) {
return $text;
}
// nl2br() hängt das <br> VOR den vorhandenen Zeilenumbruch — der folgende
// Umbruch wird deshalb mitgeschluckt, sonst verdoppeln sich die Leerzeilen.
$text = preg_replace('/<br\s*\/?>\r?\n?/i', "\n", $text);
return html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Erzwingt einen einzeiligen Mail-Header-Wert.
*
* Betreffzeilen werden aus DB-Werten (Kundenname, Rechnungsreferenz) zusammen-
* gebaut. Ein CR/LF darin könnte in einen zusätzlichen Header umschlagen
* (Header-Injection) — deshalb werden alle Steuerzeichen durch ein Leerzeichen
* ersetzt und Mehrfach-Leerzeichen zusammengefasst. Die Ersetzung arbeitet
* bewusst byteweise ohne /u-Modifier: UTF-8-Folgebytes liegen alle >= 0x80,
* Umlaute bleiben also unangetastet, und ungültiges UTF-8 kann die Regex nicht
* scheitern lassen.
*
* @param string $text Roher Headerwert
* @return string Einzeiliger, getrimmter Headerwert
*/
function mahnungHeaderEinzeilig($text)
{
$text = preg_replace('/[\x00-\x1F\x7F]+/', ' ', (string) $text);
$text = preg_replace('/ {2,}/', ' ', (string) $text);
return trim((string) $text);
}
// ---------------------------------------------------------------------------
// Absender
// ---------------------------------------------------------------------------
/**
* Absenderadresse der Firma.
*
* Bewusst NICHT die Adresse des angemeldeten Benutzers: eine Zahlungserinnerung
* geht im Namen des Betriebs raus, nicht im Namen dessen, der gerade klickt.
* Deshalb wird der Absender im Formular nur angezeigt und nicht zur Auswahl
* gestellt; ein trotzdem geposteter Wert wird ignoriert.
*
* ?: statt ??, damit ein LEERER Firmen-Mailwert auf die globale Absenderadresse
* zurückfällt.
*
* @param string $fehler Rückgabe: Fehlertext ('' = alles gut)
* @return array{name:string,mail:string,voll:string}
*/
function mahnungAbsender(&$fehler)
{
global $langs, $mysoc;
$fehler = '';
// Reihenfolge: eigene Absenderadresse des Moduls (Setup) -> Firmenadresse ->
// globale Dolibarr-Absenderadresse. ?: statt ??, damit ein LEERER Wert jeweils
// auf die nächste Stufe durchfällt.
$fromEmail = trim(getDolGlobalString('MAHNUNG_EMAIL_SENDER'))
?: trim((string) (isset($mysoc->email) ? $mysoc->email : ''))
?: trim(getDolGlobalString('MAIN_MAIL_EMAIL_FROM'));
if ($fromEmail === '' || !isValidEmail($fromEmail)) {
$fehler = $langs->trans('MahnungMailKeinAbsender');
return array('name' => '', 'mail' => '', 'voll' => '');
}
// Anzeigename: eigener Name aus dem Setup, sonst der Firmenname.
// mahnungHeaderEinzeilig() zuerst — siehe mahnungEmpfaengerAdresse(): CR/LF im
// Namen würde dol_string_nospecial() stehen lassen.
$fromNameRoh = trim(getDolGlobalString('MAHNUNG_EMAIL_SENDER_NAME'))
?: (string) (isset($mysoc->name) ? $mysoc->name : '');
$fromName = trim(dol_string_nospecial(mahnungHeaderEinzeilig($fromNameRoh), ' ', array(',', ';', '<', '>', '"')));
return array(
'name' => $fromName,
'mail' => $fromEmail,
'voll' => ($fromName !== '' ? $fromName.' <'.$fromEmail.'>' : $fromEmail),
);
}
// ---------------------------------------------------------------------------
// Versand
// ---------------------------------------------------------------------------
/**
* Statuswächter + fachliche Sperre.
*
* @param Mahnung $mahnung Geladener Mahnvorgang
* @param ?MahnungStufe $stufeObj Geladene Stufenkonfiguration
* @param bool $force Erneuter Versand einer bereits versendeten Erinnerung
* @param string $fehler Rückgabe: Grund der Ablehnung
* @return bool true = Versand erlaubt
*/
function mahnungVersandErlaubt($mahnung, $stufeObj, $force, &$fehler)
{
global $langs;
$fehler = '';
// Hinweis: $langs->trans() jagt den fertigen Text inkl. der eingesetzten
// Parameter durch htmlentities(). Die aus der DB stammenden Werte deshalb
// NICHT zusätzlich mit dol_escape_htmltag() behandeln — doppeltes Encoding.
$status = (int) $mahnung->status;
if ($status === Mahnung::STATUS_STORNIERT) {
$fehler = $langs->trans('MahnungMailStatusStorniert', $mahnung->ref);
return false;
}
if ($status === Mahnung::STATUS_ERLEDIGT) {
$fehler = $langs->trans('MahnungMailStatusErledigt', $mahnung->ref);
return false;
}
if ($status >= Mahnung::STATUS_VERSENDET && !$force) {
$fehler = $langs->trans(
'MahnungMailBereitsVersendet',
$mahnung->ref,
$mahnung->date_versand ? dol_print_date($mahnung->date_versand, 'dayhour') : '-'
);
return false;
}
// Bewusste fachliche Sperre: echte Mahnungen werden per Post/Einschreiben
// versendet, weil eine E-Mail nicht beweisbar zugestellt ist.
if (!is_object($stufeObj) || !$stufeObj->istErinnerung()) {
$fehler = $langs->trans('MahnungMailNurErinnerung');
return false;
}
return true;
}
/**
* Verschickt die Zahlungserinnerung — der EINZIGE Weg, auf dem dieses Modul
* eine Erinnerungsmail versendet.
*
* Ablauf: Statuswächter -> atomare Versand-Reservierung -> CMailFile ->
* bei Sendefehler Reservierung zurücknehmen.
*
* @param Mahnung $mahnung Geladener Mahnvorgang (wird bei Erfolg mitgeführt)
* @param MahnungStufe $stufeObj Geladene Stufenkonfiguration
* @param array $mail to, cc, bcc, from, subject, body, ishtml,
* paths, names, mimes, deliveryreceipt,
* trackid, force, frist
* @param User $user Bearbeitender Benutzer
* @param string $fehler Rückgabe: Fehlertext ('' = alles gut)
* @return bool true = Mail ist raus
*/
function mahnungSendeErinnerungsMail($mahnung, $stufeObj, $mail, $user, &$fehler)
{
global $langs;
$fehler = '';
$force = !empty($mail['force']);
if (!mahnungVersandErlaubt($mahnung, $stufeObj, $force, $fehler)) {
return false;
}
if (trim((string) $mail['to']) === '') {
$fehler = $langs->trans('MahnungMailKeinEmpfaenger');
return false;
}
if (trim((string) $mail['from']) === '') {
$fehler = $langs->trans('MahnungMailKeinAbsender');
return false;
}
// -----------------------------------------------------------------------
// Versand ATOMAR reservieren — zwingend VOR dem Senden
// -----------------------------------------------------------------------
// Zwischen dem Statuswächter und dem Versand liegen mehrere DB- und
// Dateizugriffe (Stufe, Kunde, Rechnung, ggf. PDF-Erzeugung). Ohne Sperre
// kämen zwei parallele Klicks beide durch und die Erinnerung ginge doppelt
// raus. Deshalb wird der Vorgang JETZT per bedingtem UPDATE auf VERSENDET
// gesetzt: senden darf nur der Request, dessen UPDATE tatsächlich eine Zeile
// getroffen hat. Weil die Statusfortschreibung damit VOR dem Versand liegt,
// kann ein erfolgreicher Versand auch nicht mehr durch ein fehlgeschlagenes
// UPDATE "unsichtbar" werden. Die umgekehrte Richtung (Status gesetzt, Mail
// nicht raus) wird unten sauber zurückgerollt.
//
// Mitgeschrieben wird derselbe offene Betrag, den die Mail nennt (siehe
// mahnungRechnungNochOffen()) — sonst stünde in der Karte weiter der Stand
// vom Anlagezeitpunkt.
$statusVorVersand = (int) $mahnung->status;
$dateVersandVorher = $mahnung->date_versand;
$versandwegVorher = $mahnung->versandweg;
$fristVorher = $mahnung->date_lim_reglement_neu;
$reserviertAm = dol_now();
$neueFrist = !empty($mail['frist']) ? (int) $mail['frist'] : $fristVorher;
$sperrFehler = '';
if (!mahnungReserviereVersand($mahnung, $statusVorVersand, $force, $reserviertAm, $neueFrist, (int) $user->id, $sperrFehler)) {
if ($sperrFehler !== '') {
dol_syslog('mahnung/sendmail: Versand-Reservierung fehlgeschlagen: '.$sperrFehler, LOG_ERR);
$fehler = $langs->trans('MahnungMailVersandSperreFehler', $sperrFehler);
return false;
}
// Kein SQL-Fehler, aber keine Zeile getroffen: ein paralleler Request war
// schneller oder der Vorgang wurde zwischenzeitlich verändert (Storno,
// Zahlungseingang). In beiden Fällen wird bewusst NICHT gesendet.
dol_syslog('mahnung/sendmail: Versand von '.$mahnung->ref.' bereits reserviert — kein zweiter Versand', LOG_WARNING);
$fehler = $langs->trans('MahnungMailVersandLaeuft', $mahnung->ref);
return false;
}
// Reservierung steht — das geladene Objekt auf denselben Stand bringen.
$mahnung->status = Mahnung::STATUS_VERSENDET;
$mahnung->date_versand = $reserviertAm;
$mahnung->versandweg = 'email';
$mahnung->date_lim_reglement_neu = $neueFrist;
// -----------------------------------------------------------------------
// Versand
// -----------------------------------------------------------------------
$mailFile = new CMailFile(
(string) $mail['subject'],
(string) $mail['to'],
(string) $mail['from'],
(string) $mail['body'],
isset($mail['paths']) && is_array($mail['paths']) ? $mail['paths'] : array(),
isset($mail['mimes']) && is_array($mail['mimes']) ? $mail['mimes'] : array(),
isset($mail['names']) && is_array($mail['names']) ? $mail['names'] : array(),
(string) (isset($mail['cc']) ? $mail['cc'] : ''),
(string) (isset($mail['bcc']) ? $mail['bcc'] : ''),
empty($mail['deliveryreceipt']) ? 0 : 1,
(int) $mail['ishtml'],
'',
'',
(string) (isset($mail['trackid']) ? $mail['trackid'] : '')
);
// Ab hier ist der Vorgang reserviert: jeder Fehlerausstieg muss die
// Reservierung wieder zurücknehmen, sonst gälte die Erinnerung als versendet,
// ohne dass sie raus ist.
$sendeFehler = '';
if (!empty($mailFile->error) || !empty($mailFile->errors)) {
$sendeFehler = !empty($mailFile->error) ? $mailFile->error : implode(', ', (array) $mailFile->errors);
} elseif (!$mailFile->sendfile()) {
$sendeFehler = !empty($mailFile->error) ? $mailFile->error : implode(', ', (array) $mailFile->errors);
if (trim($sendeFehler) === '') {
// sendfile() meldet nicht immer einen Text — der häufigste stille Grund
// ist die global abgeschaltete Mailfunktion.
$sendeFehler = getDolGlobalString('MAIN_DISABLE_ALL_MAILS')
? 'MAIN_DISABLE_ALL_MAILS'
: $langs->transnoentities('MahnungMailUnbekannterFehler');
}
}
if ($sendeFehler !== '') {
$fehler = $langs->trans('MahnungEmailFehlgeschlagen', $sendeFehler);
$rueckFehler = '';
if (!mahnungVersandZuruecknehmen($mahnung, $statusVorVersand, $dateVersandVorher, $versandwegVorher, $fristVorher, $reserviertAm, $rueckFehler)) {
// Status steht jetzt auf VERSENDET, obwohl nichts raus ist — das muss der
// Benutzer erfahren, sonst hält er die Erinnerung für zugestellt.
dol_syslog('mahnung/sendmail: Ruecknahme der Versand-Reservierung fehlgeschlagen: '.$rueckFehler, LOG_ERR);
$fehler .= ' '.$langs->trans('MahnungMailStatusNichtZurueckgesetzt', $rueckFehler);
} else {
// Objekt wieder auf den Stand vor der Reservierung bringen, damit die
// Karte nach dem Fehlschlag nicht "versendet" anzeigt.
$mahnung->status = $statusVorVersand;
$mahnung->date_versand = $dateVersandVorher;
$mahnung->versandweg = $versandwegVorher;
$mahnung->date_lim_reglement_neu = $fristVorher;
}
return false;
}
// Festhalten, was tatsächlich rausgegangen ist. Bewusst NACH dem erfolgreichen
// Versand: protokolliert wird nur, was den Mailserver auch verlassen hat.
// Ein Fehler beim Protokollieren darf den Versand nicht nachträglich als
// gescheitert erscheinen lassen — er landet im Syslog, mehr nicht.
mahnungProtokolliereVersand($mahnung, $mail, $user, $reserviertAm);
// Fertig — Status/Versanddaten stehen bereits aus der Reservierung in der DB,
// pdf_path bleibt bewusst leer (kein Mahn-PDF).
return true;
}
/**
* Schreibt einen versendeten Mailtext ins Protokoll.
*
* Gespeichert wird der Stand zum Sendezeitpunkt — ändert jemand später die
* Vorlage in der Stufen-Konfiguration, bleibt der Nachweis davon unberührt.
*
* @param Mahnung $mahnung Geladener Mahnvorgang
* @param array $mail Dieselben Werte, die an CMailFile gingen
* @param User $user Absendender Benutzer
* @param int $jetzt Versandzeitpunkt (Unix-Zeit)
* @return bool true = protokolliert
*/
function mahnungProtokolliereVersand($mahnung, $mail, $user, $jetzt)
{
global $db, $conf;
$namen = (isset($mail['names']) && is_array($mail['names'])) ? $mail['names'] : array();
$sql = "INSERT INTO ".MAIN_DB_PREFIX."mahnung_mailprotokoll";
$sql .= " (entity, fk_mahnung, date_versand, mail_from, mail_to, mail_cc, mail_bcc,";
$sql .= " subject, body, ishtml, anhaenge, fk_user, datec) VALUES (";
$sql .= ((int) $conf->entity);
$sql .= ", ".((int) $mahnung->id);
$sql .= ", '".$db->idate($jetzt)."'";
$sql .= ", '".$db->escape((string) (isset($mail['from']) ? $mail['from'] : ''))."'";
$sql .= ", '".$db->escape((string) (isset($mail['to']) ? $mail['to'] : ''))."'";
$sql .= ", '".$db->escape((string) (isset($mail['cc']) ? $mail['cc'] : ''))."'";
$sql .= ", '".$db->escape((string) (isset($mail['bcc']) ? $mail['bcc'] : ''))."'";
// Spalte ist VARCHAR(255) — ein überlanger Betreff darf den INSERT nicht kippen.
$sql .= ", '".$db->escape(dol_trunc((string) (isset($mail['subject']) ? $mail['subject'] : ''), 250, 'right', 'UTF-8', 1))."'";
$sql .= ", '".$db->escape((string) (isset($mail['body']) ? $mail['body'] : ''))."'";
$sql .= ", ".(empty($mail['ishtml']) ? 0 : 1);
$sql .= ", '".$db->escape(implode('; ', $namen))."'";
$sql .= ", ".((int) $user->id);
$sql .= ", '".$db->idate(dol_now())."'";
$sql .= ")";
if (!$db->query($sql)) {
dol_syslog('mahnung/sendmail: Mailprotokoll konnte nicht geschrieben werden: '.$db->lasterror(), LOG_ERR);
return false;
}
return true;
}
/**
* Liest das Versandprotokoll eines Mahnvorgangs, neuester Versand zuerst.
*
* @param int $mahnungId Mahnvorgang
* @return array<int,object>
*/
function mahnungHoleMailProtokoll($mahnungId)
{
global $db;
$eintraege = array();
$sql = "SELECT rowid, date_versand, mail_from, mail_to, mail_cc, mail_bcc,";
$sql .= " subject, body, ishtml, anhaenge, fk_user";
$sql .= " FROM ".MAIN_DB_PREFIX."mahnung_mailprotokoll";
$sql .= " WHERE fk_mahnung = ".((int) $mahnungId);
$sql .= " AND entity IN (".getEntity('mahnung').")";
$sql .= " ORDER BY date_versand DESC, rowid DESC";
$resql = $db->query($sql);
if (!$resql) {
// Fehlt die Tabelle (Deploy ohne Migration), ist das kein Grund, die Karte
// scheitern zu lassen — der Block bleibt dann einfach leer.
dol_syslog('mahnung: Mailprotokoll nicht lesbar: '.$db->lasterror(), LOG_WARNING);
return $eintraege;
}
while ($obj = $db->fetch_object($resql)) {
$obj->date_versand = $db->jdate($obj->date_versand);
$eintraege[] = $obj;
}
$db->free($resql);
return $eintraege;
}
/**
* Reserviert den Versand atomar.
*
* Setzt Status, Versanddatum, Versandweg, die neu berechnete Zahlungsfrist sowie
* den frisch bestimmten offenen Betrag per bedingtem UPDATE und liefert nur dann
* true, wenn genau eine Zeile getroffen wurde. Damit kann von zwei parallelen
* Requests nur einer senden.
*
* betrag_offen/summe_mahnung kommen direkt aus dem übergebenen Objekt — sie
* wurden dort bereits nachgezogen (mahnungRechnungNochOffen()), damit Mail und
* Datensatz denselben Betrag nennen.
*
* @param Mahnung $mahnung Geladener Mahnvorgang (liefert die Ausgangswerte)
* @param int $status Status, der im Wächter oben gelesen wurde
* @param bool $force Erneuter Versand einer bereits versendeten Erinnerung
* @param int $jetzt Versandzeitpunkt (Unix-Zeit)
* @param int $neueFrist Neu berechnete Zahlungsfrist (Unix-Zeit)
* @param int $userId Bearbeitender Benutzer
* @param string $fehler Rückgabe: DB-Fehlertext ('' = kein SQL-Fehler)
* @return bool true = reserviert, false = nicht senden
*/
function mahnungReserviereVersand($mahnung, $status, $force, $jetzt, $neueFrist, $userId, &$fehler)
{
global $db;
$fehler = '';
$sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_mahnung SET";
$sql .= " status = ".((int) Mahnung::STATUS_VERSENDET);
$sql .= ", date_versand = '".$db->idate($jetzt)."'";
$sql .= ", versandweg = 'email'";
$sql .= ", date_lim_reglement_neu = ".($neueFrist ? "'".$db->idate($neueFrist)."'" : "NULL");
$sql .= ", betrag_offen = ".((float) $mahnung->betrag_offen);
$sql .= ", summe_mahnung = ".((float) $mahnung->summe_mahnung);
$sql .= ", fk_user_modif = ".((int) $userId);
$sql .= " WHERE rowid = ".((int) $mahnung->id);
$sql .= " AND entity IN (".getEntity('mahnung').")";
// Exakt der Status, der oben geprüft wurde. Hat ihn zwischenzeitlich jemand
// verändert (paralleler Versand, Storno, Zahlungseingang), greift die
// Bedingung nicht mehr — und es wird nicht gesendet.
$sql .= " AND status = ".((int) $status);
if ($force) {
// Beim erzwungenen erneuten Versand bleibt der Status gleich (VERSENDET),
// die Statusbedingung allein ließe also beide Parallelklicks durch.
// Deshalb zusätzlich das bisherige Versanddatum festnageln.
$sql .= " AND date_versand ".($mahnung->date_versand ? "= '".$db->idate($mahnung->date_versand)."'" : "IS NULL");
}
dol_syslog('mahnung/sendmail: Versand reservieren fuer Mahnung '.((int) $mahnung->id), LOG_DEBUG);
$resql = $db->query($sql);
if (!$resql) {
$fehler = $db->lasterror();
return false;
}
// Hinweis: MySQL meldet 0 betroffene Zeilen auch dann, wenn die WHERE-Bedingung
// passt, sich aber kein einziger Wert ändert. Praktisch nur beim erzwungenen
// erneuten Versand innerhalb derselben Sekunde möglich — dann wird der Versand
// abgelehnt, was die sichere Richtung ist.
return ((int) $db->affected_rows($resql) === 1);
}
/**
* Nimmt eine Versand-Reservierung zurück, wenn die Mail doch nicht rausging.
*
* Zurückgesetzt wird ausschließlich die EIGENE Reservierung (Status VERSENDET
* mit genau unserem Versandzeitpunkt) — ein zwischenzeitlich erfolgreicher
* anderer Vorgang darf nicht überschrieben werden.
*
* betrag_offen/summe_mahnung bleiben bewusst auf dem nachgezogenen Stand: der
* Kunde hat tatsächlich (teil)gezahlt, das ist auch dann richtig, wenn die Mail
* nicht rausging.
*
* @param Mahnung $mahnung Geladener Mahnvorgang
* @param int $status Status vor der Reservierung
* @param int|null $dateVersandAlt Versanddatum vor der Reservierung
* @param string|null $versandwegAlt Versandweg vor der Reservierung
* @param int|null $fristAlt Zahlungsfrist vor der Reservierung
* @param int $reserviertAm Unser Versandzeitpunkt (Unix-Zeit)
* @param string $fehler Rückgabe: Fehlertext ('' = alles gut)
* @return bool true = zurückgesetzt
*/
function mahnungVersandZuruecknehmen($mahnung, $status, $dateVersandAlt, $versandwegAlt, $fristAlt, $reserviertAm, &$fehler)
{
global $db;
$fehler = '';
$sql = "UPDATE ".MAIN_DB_PREFIX."mahnung_mahnung SET";
$sql .= " status = ".((int) $status);
$sql .= ", date_versand = ".($dateVersandAlt ? "'".$db->idate($dateVersandAlt)."'" : "NULL");
$sql .= ", versandweg = ".($versandwegAlt ? "'".$db->escape($versandwegAlt)."'" : "NULL");
$sql .= ", date_lim_reglement_neu = ".($fristAlt ? "'".$db->idate($fristAlt)."'" : "NULL");
$sql .= " WHERE rowid = ".((int) $mahnung->id);
$sql .= " AND entity IN (".getEntity('mahnung').")";
$sql .= " AND status = ".((int) Mahnung::STATUS_VERSENDET);
$sql .= " AND date_versand = '".$db->idate($reserviertAm)."'";
dol_syslog('mahnung/sendmail: Versand-Reservierung zuruecknehmen fuer Mahnung '.((int) $mahnung->id), LOG_DEBUG);
$resql = $db->query($sql);
if (!$resql) {
$fehler = $db->lasterror();
return false;
}
if ((int) $db->affected_rows($resql) !== 1) {
$fehler = 'Datensatz wurde zwischenzeitlich veraendert';
return false;
}
return true;
}