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>
1215 lines
49 KiB
PHP
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üß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;
|
|
}
|