Feature: Rechnungsversand per E-Mail (eigener SMTP-Client, kein PHPMailer)
This commit is contained in:
parent
e0bab2e49d
commit
4485acbe1f
10 changed files with 515 additions and 10 deletions
8
migrations/039_smtp_settings.sql
Normal file
8
migrations/039_smtp_settings.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
ALTER TABLE company_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS mail_smtp_host VARCHAR(255) NOT NULL DEFAULT '' AFTER nextcloud_done,
|
||||||
|
ADD COLUMN IF NOT EXISTS mail_smtp_port SMALLINT UNSIGNED NOT NULL DEFAULT 587 AFTER mail_smtp_host,
|
||||||
|
ADD COLUMN IF NOT EXISTS mail_smtp_encryption VARCHAR(10) NOT NULL DEFAULT 'tls' AFTER mail_smtp_port,
|
||||||
|
ADD COLUMN IF NOT EXISTS mail_smtp_user VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_smtp_encryption,
|
||||||
|
ADD COLUMN IF NOT EXISTS mail_smtp_pass VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_smtp_user,
|
||||||
|
ADD COLUMN IF NOT EXISTS mail_from_name VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_smtp_pass,
|
||||||
|
ADD COLUMN IF NOT EXISTS mail_from_email VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_from_name;
|
||||||
|
|
@ -103,6 +103,7 @@ $router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->canc
|
||||||
$router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->destroy($p));
|
$router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->destroy($p));
|
||||||
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
|
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
|
||||||
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
|
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
|
||||||
|
$router->post('/invoices/{id}/send', fn($p) => (new InvoiceController())->send($p));
|
||||||
|
|
||||||
// ── Anlagevermögen ───────────────────────────────────────────
|
// ── Anlagevermögen ───────────────────────────────────────────
|
||||||
$router->get('/assets', fn($p) => (new AssetController())->index($p));
|
$router->get('/assets', fn($p) => (new AssetController())->index($p));
|
||||||
|
|
@ -130,6 +131,7 @@ $router->get('/tax/euer/{year}/konto/{konto_id}', fn($p) => (new EuerController(
|
||||||
// ── Einstellungen ────────────────────────────────────────────
|
// ── Einstellungen ────────────────────────────────────────────
|
||||||
$router->get('/settings', fn($p) => (new SettingsController())->show($p));
|
$router->get('/settings', fn($p) => (new SettingsController())->show($p));
|
||||||
$router->post('/settings', fn($p) => (new SettingsController())->update($p));
|
$router->post('/settings', fn($p) => (new SettingsController())->update($p));
|
||||||
|
$router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($p));
|
||||||
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
|
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
|
||||||
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
|
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use Steuerkram\Models\Beleg;
|
||||||
use Steuerkram\Models\Booking;
|
use Steuerkram\Models\Booking;
|
||||||
use Steuerkram\Models\Account;
|
use Steuerkram\Models\Account;
|
||||||
use Steuerkram\Models\Setting;
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Services\EmailService;
|
||||||
use Steuerkram\Services\PdfInvoiceService;
|
use Steuerkram\Services\PdfInvoiceService;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
|
@ -197,6 +198,50 @@ class InvoiceController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function send(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$invoice = Invoice::find($id);
|
||||||
|
if (!$invoice) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
if ($invoice['status'] === 'Entwurf') {
|
||||||
|
View::redirect("/invoices/{$id}", 'Entwürfe können nicht gesendet werden.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empfänger aus POST (bis zu 3), Fallback: Kunden-E-Mail
|
||||||
|
$recipients = array_values(array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
array_merge(
|
||||||
|
[$_POST['email_to'] ?? $invoice['email'] ?? ''],
|
||||||
|
[$_POST['email_cc1'] ?? ''],
|
||||||
|
[$_POST['email_cc2'] ?? ''],
|
||||||
|
)
|
||||||
|
)));
|
||||||
|
|
||||||
|
if (empty($recipients)) {
|
||||||
|
View::redirect("/invoices/{$id}", 'Keine Empfänger-E-Mail-Adresse angegeben.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$lines = Invoice::lines($id);
|
||||||
|
$settings = Setting::get();
|
||||||
|
(new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients);
|
||||||
|
|
||||||
|
if ($invoice['status'] === 'Finalisiert') {
|
||||||
|
\Steuerkram\Database::execute(
|
||||||
|
"UPDATE invoices SET status='Gesendet', updated_at=NOW() WHERE id=?",
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gesendet.');
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
View::redirect("/invoices/{$id}", 'Fehler beim Senden: ' . $e->getMessage(), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function pdf(array $params): void
|
public function pdf(array $params): void
|
||||||
{
|
{
|
||||||
$invoice = Invoice::find((int)$params['id']);
|
$invoice = Invoice::find((int)$params['id']);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Steuerkram\Controllers;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
use Steuerkram\Models\Setting;
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Services\EmailService;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
||||||
class SettingsController
|
class SettingsController
|
||||||
|
|
@ -89,6 +90,25 @@ class SettingsController
|
||||||
View::redirect('/settings', 'Logo hochgeladen.');
|
View::redirect('/settings', 'Logo hochgeladen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mailTest(array $params): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$settings = Setting::get();
|
||||||
|
$to = $settings['email'] ?? '';
|
||||||
|
|
||||||
|
if (empty($to)) {
|
||||||
|
echo json_encode(['error' => 'Keine Firmen-E-Mail hinterlegt (Einstellungen → Firmendaten).']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new EmailService())->sendTestMail($settings, $to);
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function deleteLogo(array $params): void
|
public function deleteLogo(array $params): void
|
||||||
{
|
{
|
||||||
Setting::deleteLogo();
|
Setting::deleteLogo();
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ class Setting
|
||||||
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
||||||
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
||||||
nextcloud_folder=?, nextcloud_done=?,
|
nextcloud_folder=?, nextcloud_done=?,
|
||||||
|
mail_smtp_host=?, mail_smtp_port=?, mail_smtp_encryption=?,
|
||||||
|
mail_smtp_user=?, mail_smtp_pass=?,
|
||||||
|
mail_from_name=?, mail_from_email=?,
|
||||||
logo_max_width=?, logo_max_height=?,
|
logo_max_width=?, logo_max_height=?,
|
||||||
logo_position=?, logo_margin_side=?, logo_margin_top=?
|
logo_position=?, logo_margin_side=?, logo_margin_top=?
|
||||||
WHERE id=1',
|
WHERE id=1',
|
||||||
|
|
@ -68,6 +71,13 @@ class Setting
|
||||||
$data['nextcloud_pass'] ?: null,
|
$data['nextcloud_pass'] ?: null,
|
||||||
$data['nextcloud_folder'] ?: '/Belege/Import',
|
$data['nextcloud_folder'] ?: '/Belege/Import',
|
||||||
$data['nextcloud_done'] ?: null,
|
$data['nextcloud_done'] ?: null,
|
||||||
|
$data['mail_smtp_host'] ?: '',
|
||||||
|
(int)($data['mail_smtp_port'] ?? 587),
|
||||||
|
in_array($data['mail_smtp_encryption'] ?? '', ['tls', 'ssl', 'none']) ? $data['mail_smtp_encryption'] : 'tls',
|
||||||
|
$data['mail_smtp_user'] ?: '',
|
||||||
|
$data['mail_smtp_pass'] ?: '',
|
||||||
|
$data['mail_from_name'] ?: '',
|
||||||
|
$data['mail_from_email'] ?: '',
|
||||||
max(50, (int)($data['logo_max_width'] ?? 600)),
|
max(50, (int)($data['logo_max_width'] ?? 600)),
|
||||||
max(20, (int)($data['logo_max_height'] ?? 100)),
|
max(20, (int)($data['logo_max_height'] ?? 100)),
|
||||||
$logoPosition,
|
$logoPosition,
|
||||||
|
|
|
||||||
68
src/Services/EmailService.php
Normal file
68
src/Services/EmailService.php
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
class EmailService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Sendet eine Rechnung als PDF-Anhang an alle Empfänger.
|
||||||
|
*
|
||||||
|
* @param string[] $recipients E-Mail-Adressen (1–3)
|
||||||
|
* @throws \RuntimeException
|
||||||
|
*/
|
||||||
|
public function sendInvoice(array $invoice, array $lines, array $settings, array $recipients): void
|
||||||
|
{
|
||||||
|
$mailer = $this->buildMailer($settings);
|
||||||
|
|
||||||
|
$pdfService = new PdfInvoiceService();
|
||||||
|
$pdfContent = $pdfService->renderToString($invoice, $lines, $settings);
|
||||||
|
|
||||||
|
$nr = $invoice['rechnungs_nr'] ?? 'Entwurf';
|
||||||
|
$firma = $settings['firma'] ?? '';
|
||||||
|
$subject = 'Rechnung ' . $nr;
|
||||||
|
$filename = 'Rechnung-' . $nr . '.pdf';
|
||||||
|
|
||||||
|
$body = "Guten Tag,\n\n"
|
||||||
|
. "anbei erhalten Sie die Rechnung {$nr} von {$firma}.\n\n"
|
||||||
|
. "Bei Fragen stehen wir Ihnen gerne zur Verfügung.\n\n"
|
||||||
|
. "Mit freundlichen Grüßen\n"
|
||||||
|
. $firma;
|
||||||
|
|
||||||
|
$mailer->send($recipients, $subject, $body, $pdfContent, $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet eine Test-Mail an die angegebene Adresse.
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException
|
||||||
|
*/
|
||||||
|
public function sendTestMail(array $settings, string $to): void
|
||||||
|
{
|
||||||
|
$mailer = $this->buildMailer($settings);
|
||||||
|
$mailer->send(
|
||||||
|
[$to],
|
||||||
|
'Steuerkram – SMTP-Test',
|
||||||
|
"Die SMTP-Konfiguration funktioniert korrekt.\n\nDiese Mail wurde von Steuerkram gesendet."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMailer(array $settings): SmtpMailer
|
||||||
|
{
|
||||||
|
$host = $settings['mail_smtp_host'] ?? '';
|
||||||
|
if (empty($host)) {
|
||||||
|
throw new \RuntimeException('SMTP-Host ist nicht konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SmtpMailer(
|
||||||
|
host: $host,
|
||||||
|
port: (int)($settings['mail_smtp_port'] ?? 587),
|
||||||
|
encryption: $settings['mail_smtp_encryption'] ?? 'tls',
|
||||||
|
user: $settings['mail_smtp_user'] ?? '',
|
||||||
|
pass: $settings['mail_smtp_pass'] ?? '',
|
||||||
|
fromEmail: $settings['mail_from_email'] ?: ($settings['email'] ?? ''),
|
||||||
|
fromName: $settings['mail_from_name'] ?: ($settings['firma'] ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,24 @@ use Mpdf\Mpdf;
|
||||||
|
|
||||||
class PdfInvoiceService
|
class PdfInvoiceService
|
||||||
{
|
{
|
||||||
|
public function renderToString(array $invoice, array $lines, array $settings): string
|
||||||
|
{
|
||||||
|
return $this->generate($invoice, $lines, $settings);
|
||||||
|
}
|
||||||
|
|
||||||
public function render(array $invoice, array $lines, array $settings): void
|
public function render(array $invoice, array $lines, array $settings): void
|
||||||
|
{
|
||||||
|
$pdfContent = $this->generate($invoice, $lines, $settings);
|
||||||
|
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
|
||||||
|
|
||||||
|
header('Content-Type: application/pdf');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
header('Content-Length: ' . strlen($pdfContent));
|
||||||
|
echo $pdfContent;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generate(array $invoice, array $lines, array $settings): string
|
||||||
{
|
{
|
||||||
$html = $this->buildHtml($invoice, $lines, $settings);
|
$html = $this->buildHtml($invoice, $lines, $settings);
|
||||||
|
|
||||||
|
|
@ -40,18 +57,11 @@ class PdfInvoiceService
|
||||||
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
|
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
|
||||||
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
|
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
|
||||||
|
|
||||||
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
|
|
||||||
$pdfContent = $mpdf->Output('', 'S');
|
$pdfContent = $mpdf->Output('', 'S');
|
||||||
|
|
||||||
// ZUGFeRD EN16931-XML einbetten (E-Rechnung)
|
// ZUGFeRD EN16931-XML einbetten (E-Rechnung)
|
||||||
$zugferd = new ZugferdService();
|
$zugferd = new ZugferdService();
|
||||||
$pdfContent = $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings);
|
return $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings);
|
||||||
|
|
||||||
header('Content-Type: application/pdf');
|
|
||||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
|
||||||
header('Content-Length: ' . strlen($pdfContent));
|
|
||||||
echo $pdfContent;
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildHtml(array $inv, array $lines, array $settings): string
|
private function buildHtml(array $inv, array $lines, array $settings): string
|
||||||
|
|
|
||||||
237
src/Services/SmtpMailer.php
Normal file
237
src/Services/SmtpMailer.php
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schlanker SMTP-Client ohne externe Abhängigkeiten.
|
||||||
|
* Unterstützt STARTTLS (port 587), SSL/TLS (port 465) und unverschlüsselt.
|
||||||
|
* AUTH LOGIN und AUTH PLAIN. Bis zu n Empfänger. Einen Anhang (binary string).
|
||||||
|
*/
|
||||||
|
class SmtpMailer
|
||||||
|
{
|
||||||
|
/** @var resource */
|
||||||
|
private $sock;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $host,
|
||||||
|
private readonly int $port,
|
||||||
|
private readonly string $encryption, // 'tls' | 'ssl' | 'none'
|
||||||
|
private readonly string $user,
|
||||||
|
private readonly string $pass,
|
||||||
|
private readonly string $fromEmail,
|
||||||
|
private readonly string $fromName,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $to Liste von Empfänger-Adressen (max. 3)
|
||||||
|
* @param string $subject
|
||||||
|
* @param string $body Plain-Text-Body
|
||||||
|
* @param string|null $attachContent Binärer Inhalt des Anhangs
|
||||||
|
* @param string|null $attachName Dateiname des Anhangs
|
||||||
|
* @throws \RuntimeException
|
||||||
|
*/
|
||||||
|
public function send(
|
||||||
|
array $to,
|
||||||
|
string $subject,
|
||||||
|
string $body,
|
||||||
|
?string $attachContent = null,
|
||||||
|
?string $attachName = null,
|
||||||
|
): void {
|
||||||
|
$this->connect();
|
||||||
|
$this->authenticate();
|
||||||
|
$this->sendMessage($to, $subject, $body, $attachContent, $attachName);
|
||||||
|
$this->cmd('QUIT');
|
||||||
|
fclose($this->sock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verbindung ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function connect(): void
|
||||||
|
{
|
||||||
|
$address = $this->encryption === 'ssl'
|
||||||
|
? 'ssl://' . $this->host
|
||||||
|
: 'tcp://' . $this->host;
|
||||||
|
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => true,
|
||||||
|
'verify_peer_name' => true,
|
||||||
|
'allow_self_signed' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sock = stream_socket_client(
|
||||||
|
$address . ':' . $this->port,
|
||||||
|
$errno, $errstr, 15,
|
||||||
|
STREAM_CLIENT_CONNECT,
|
||||||
|
$ctx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$sock) {
|
||||||
|
throw new \RuntimeException("SMTP-Verbindung fehlgeschlagen: {$errstr} ({$errno})");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sock = $sock;
|
||||||
|
stream_set_timeout($this->sock, 15);
|
||||||
|
|
||||||
|
$this->expect(220);
|
||||||
|
$this->ehlo();
|
||||||
|
|
||||||
|
if ($this->encryption === 'tls') {
|
||||||
|
$this->cmd('STARTTLS');
|
||||||
|
$this->expect(220);
|
||||||
|
if (!stream_socket_enable_crypto($this->sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||||
|
throw new \RuntimeException('STARTTLS-Aushandlung fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
$this->ehlo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ehlo(): void
|
||||||
|
{
|
||||||
|
$this->cmd('EHLO ' . (gethostname() ?: 'localhost'));
|
||||||
|
$this->expectMulti(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authentifizierung ────────────────────────────────────────
|
||||||
|
|
||||||
|
private function authenticate(): void
|
||||||
|
{
|
||||||
|
$this->cmd('AUTH LOGIN');
|
||||||
|
$this->expect(334);
|
||||||
|
$this->cmd(base64_encode($this->user));
|
||||||
|
$this->expect(334);
|
||||||
|
$this->cmd(base64_encode($this->pass));
|
||||||
|
$this->expect(235);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nachricht ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function sendMessage(
|
||||||
|
array $to,
|
||||||
|
string $subject,
|
||||||
|
string $body,
|
||||||
|
?string $attachContent,
|
||||||
|
?string $attachName,
|
||||||
|
): void {
|
||||||
|
$this->cmd('MAIL FROM:<' . $this->fromEmail . '>');
|
||||||
|
$this->expect(250);
|
||||||
|
|
||||||
|
foreach ($to as $addr) {
|
||||||
|
$this->cmd('RCPT TO:<' . $addr . '>');
|
||||||
|
$this->expect(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cmd('DATA');
|
||||||
|
$this->expect(354);
|
||||||
|
|
||||||
|
$raw = $this->buildMessage($to, $subject, $body, $attachContent, $attachName);
|
||||||
|
|
||||||
|
// Dot-Stuffing: Zeilen, die mit '.' beginnen, bekommen einen zweiten Punkt
|
||||||
|
$raw = preg_replace('/^\.$/m', '..', $raw);
|
||||||
|
|
||||||
|
fwrite($this->sock, $raw . "\r\n.\r\n");
|
||||||
|
$this->expect(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMessage(
|
||||||
|
array $to,
|
||||||
|
string $subject,
|
||||||
|
string $body,
|
||||||
|
?string $attachContent,
|
||||||
|
?string $attachName,
|
||||||
|
): string {
|
||||||
|
$date = date('r');
|
||||||
|
$fromHdr = $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>';
|
||||||
|
$toHdr = implode(', ', $to);
|
||||||
|
$subjHdr = $this->encodeHeader($subject);
|
||||||
|
$msgId = '<' . uniqid('sk', true) . '@' . (gethostname() ?: 'localhost') . '>';
|
||||||
|
|
||||||
|
if ($attachContent === null) {
|
||||||
|
return implode("\r\n", [
|
||||||
|
'Date: ' . $date,
|
||||||
|
'Message-ID: ' . $msgId,
|
||||||
|
'From: ' . $fromHdr,
|
||||||
|
'To: ' . $toHdr,
|
||||||
|
'Subject: ' . $subjHdr,
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
'Content-Transfer-Encoding: quoted-printable',
|
||||||
|
'',
|
||||||
|
$this->quotedPrintable($body),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$boundary = '----=_Part_' . bin2hex(random_bytes(8));
|
||||||
|
$b64 = chunk_split(base64_encode($attachContent), 76, "\r\n");
|
||||||
|
$safeName = $this->encodeHeader($attachName ?? 'Anhang.pdf');
|
||||||
|
|
||||||
|
return implode("\r\n", [
|
||||||
|
'Date: ' . $date,
|
||||||
|
'Message-ID: ' . $msgId,
|
||||||
|
'From: ' . $fromHdr,
|
||||||
|
'To: ' . $toHdr,
|
||||||
|
'Subject: ' . $subjHdr,
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: multipart/mixed; boundary="' . $boundary . '"',
|
||||||
|
'',
|
||||||
|
'--' . $boundary,
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
'Content-Transfer-Encoding: quoted-printable',
|
||||||
|
'',
|
||||||
|
$this->quotedPrintable($body),
|
||||||
|
'',
|
||||||
|
'--' . $boundary,
|
||||||
|
'Content-Type: application/pdf; name="' . $safeName . '"',
|
||||||
|
'Content-Transfer-Encoding: base64',
|
||||||
|
'Content-Disposition: attachment; filename="' . $safeName . '"',
|
||||||
|
'',
|
||||||
|
$b64,
|
||||||
|
'--' . $boundary . '--',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfsmethoden ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function cmd(string $line): void
|
||||||
|
{
|
||||||
|
fwrite($this->sock, $line . "\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expect(int $code): string
|
||||||
|
{
|
||||||
|
$response = fgets($this->sock, 512);
|
||||||
|
if ($response === false) {
|
||||||
|
throw new \RuntimeException('SMTP: Keine Antwort vom Server.');
|
||||||
|
}
|
||||||
|
if ((int)$response !== $code) {
|
||||||
|
throw new \RuntimeException('SMTP: Unerwartete Antwort: ' . trim($response));
|
||||||
|
}
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest mehrzeilige SMTP-Antworten (z.B. nach EHLO) */
|
||||||
|
private function expectMulti(int $code): void
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$line = fgets($this->sock, 512);
|
||||||
|
if ($line === false) throw new \RuntimeException('SMTP: Verbindung unterbrochen.');
|
||||||
|
if ((int)$line !== $code) throw new \RuntimeException('SMTP: ' . trim($line));
|
||||||
|
} while ($line[3] === '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodeHeader(string $value): string
|
||||||
|
{
|
||||||
|
if (mb_detect_encoding($value, 'ASCII', true)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
return '=?UTF-8?B?' . base64_encode($value) . '?=';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quotedPrintable(string $text): string
|
||||||
|
{
|
||||||
|
return quoted_printable_encode($text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,11 @@ $badgeClass = match($invoice['status']) {
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($invoice['status'] !== 'Entwurf'): ?>
|
<?php if ($invoice['status'] !== 'Entwurf'): ?>
|
||||||
<a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
|
<a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-secondary">PDF herunterladen</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert'])): ?>
|
||||||
|
<button type="button" class="btn btn-primary"
|
||||||
|
onclick="document.getElementById('send-modal').style.display='flex'">Per E-Mail senden</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
||||||
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
|
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
|
||||||
|
|
@ -311,3 +315,31 @@ $badgeClass = match($invoice['status']) {
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- E-Mail-Senden-Modal -->
|
||||||
|
<div id="send-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:center;justify-content:center">
|
||||||
|
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:420px;max-width:95vw;box-shadow:0 8px 32px rgba(0,0,0,.2)">
|
||||||
|
<h3 style="margin:0 0 1rem">Rechnung per E-Mail senden</h3>
|
||||||
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/send">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Empfänger *</label>
|
||||||
|
<input type="email" name="email_to" required
|
||||||
|
value="<?= $h($invoice['email'] ?? '') ?>"
|
||||||
|
placeholder="empfaenger@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>CC 1 <span style="font-weight:normal;color:#888">(optional)</span></label>
|
||||||
|
<input type="email" name="email_cc1" placeholder="cc1@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>CC 2 <span style="font-weight:normal;color:#888">(optional)</span></label>
|
||||||
|
<input type="email" name="email_cc2" placeholder="cc2@example.com">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.25rem">
|
||||||
|
<button type="button" class="btn btn-secondary"
|
||||||
|
onclick="document.getElementById('send-modal').style.display='none'">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Senden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -256,11 +256,84 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- E-Mail-Versand -->
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">E-Mail-Versand (SMTP)</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SMTP-Host</label>
|
||||||
|
<input type="text" name="mail_smtp_host" value="<?= $h($settings['mail_smtp_host'] ?? '') ?>" placeholder="smtp.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Port</label>
|
||||||
|
<input type="number" name="mail_smtp_port" value="<?= $h($settings['mail_smtp_port'] ?? 587) ?>" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Verschlüsselung</label>
|
||||||
|
<select name="mail_smtp_encryption">
|
||||||
|
<option value="tls" <?= ($settings['mail_smtp_encryption'] ?? 'tls') === 'tls' ? 'selected' : '' ?>>STARTTLS (Port 587)</option>
|
||||||
|
<option value="ssl" <?= ($settings['mail_smtp_encryption'] ?? '') === 'ssl' ? 'selected' : '' ?>>SSL/TLS (Port 465)</option>
|
||||||
|
<option value="none" <?= ($settings['mail_smtp_encryption'] ?? '') === 'none' ? 'selected' : '' ?>>Keine</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SMTP-Benutzername</label>
|
||||||
|
<input type="text" name="mail_smtp_user" value="<?= $h($settings['mail_smtp_user'] ?? '') ?>" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SMTP-Passwort</label>
|
||||||
|
<input type="password" name="mail_smtp_pass" value="<?= $h($settings['mail_smtp_pass'] ?? '') ?>" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Absendername</label>
|
||||||
|
<input type="text" name="mail_from_name" value="<?= $h($settings['mail_from_name'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Absender-E-Mail</label>
|
||||||
|
<input type="email" name="mail_from_email" value="<?= $h($settings['mail_from_email'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary" id="btn-mail-test">Test-Mail senden</button>
|
||||||
|
<span id="mail-test-result" style="margin-left:.75rem;font-size:.8rem"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('btn-mail-test')?.addEventListener('click', function () {
|
||||||
|
var btn = this;
|
||||||
|
var result = document.getElementById('mail-test-result');
|
||||||
|
btn.disabled = true;
|
||||||
|
result.textContent = 'Sende…';
|
||||||
|
result.style.color = '';
|
||||||
|
fetch('/settings/mail-test', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.ok) {
|
||||||
|
result.textContent = '✓ Test-Mail erfolgreich gesendet.';
|
||||||
|
result.style.color = 'green';
|
||||||
|
} else {
|
||||||
|
result.textContent = '✗ ' + (data.error || 'Unbekannter Fehler');
|
||||||
|
result.style.color = 'red';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
result.textContent = '✗ Netzwerkfehler';
|
||||||
|
result.style.color = 'red';
|
||||||
|
})
|
||||||
|
.finally(function () { btn.disabled = false; });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:0">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:0">
|
||||||
<div></div>
|
<div></div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue