From 4485acbe1f2c38e79a3a2c986346c5f959ed6b4c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 28 Apr 2026 14:22:38 +0200 Subject: [PATCH] Feature: Rechnungsversand per E-Mail (eigener SMTP-Client, kein PHPMailer) --- migrations/039_smtp_settings.sql | 8 + public/index.php | 2 + src/Controllers/InvoiceController.php | 45 +++++ src/Controllers/SettingsController.php | 20 +++ src/Models/Setting.php | 10 ++ src/Services/EmailService.php | 68 +++++++ src/Services/PdfInvoiceService.php | 28 ++- src/Services/SmtpMailer.php | 237 +++++++++++++++++++++++++ src/Views/invoices/show.php | 34 +++- src/Views/settings/form.php | 73 ++++++++ 10 files changed, 515 insertions(+), 10 deletions(-) create mode 100644 migrations/039_smtp_settings.sql create mode 100644 src/Services/EmailService.php create mode 100644 src/Services/SmtpMailer.php diff --git a/migrations/039_smtp_settings.sql b/migrations/039_smtp_settings.sql new file mode 100644 index 0000000..016eeed --- /dev/null +++ b/migrations/039_smtp_settings.sql @@ -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; diff --git a/public/index.php b/public/index.php index eda11df..a8558ae 100644 --- a/public/index.php +++ b/public/index.php @@ -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}/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}/send', fn($p) => (new InvoiceController())->send($p)); // ── Anlagevermögen ─────────────────────────────────────────── $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 ──────────────────────────────────────────── $router->get('/settings', fn($p) => (new SettingsController())->show($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/delete', fn($p) => (new SettingsController())->deleteLogo($p)); diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index 2eab9b9..fff9f91 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -10,6 +10,7 @@ use Steuerkram\Models\Beleg; use Steuerkram\Models\Booking; use Steuerkram\Models\Account; use Steuerkram\Models\Setting; +use Steuerkram\Services\EmailService; use Steuerkram\Services\PdfInvoiceService; 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 { $invoice = Invoice::find((int)$params['id']); diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index 747666d..2ad8598 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram\Controllers; use Steuerkram\Models\Setting; +use Steuerkram\Services\EmailService; use Steuerkram\View; class SettingsController @@ -89,6 +90,25 @@ class SettingsController 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 { Setting::deleteLogo(); diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 85339a4..3c3a964 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -36,6 +36,9 @@ class Setting payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?, 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_position=?, logo_margin_side=?, logo_margin_top=? WHERE id=1', @@ -68,6 +71,13 @@ class Setting $data['nextcloud_pass'] ?: null, $data['nextcloud_folder'] ?: '/Belege/Import', $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(20, (int)($data['logo_max_height'] ?? 100)), $logoPosition, diff --git a/src/Services/EmailService.php b/src/Services/EmailService.php new file mode 100644 index 0000000..c7ae4fb --- /dev/null +++ b/src/Services/EmailService.php @@ -0,0 +1,68 @@ +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'] ?? ''), + ); + } +} diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index 7d31bc5..9a4b446 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -8,7 +8,24 @@ use Mpdf\Mpdf; 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 + { + $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); @@ -40,18 +57,11 @@ class PdfInvoiceService $mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS); $mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY); - $filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf'; $pdfContent = $mpdf->Output('', 'S'); // ZUGFeRD EN16931-XML einbetten (E-Rechnung) - $zugferd = new ZugferdService(); - $pdfContent = $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; + $zugferd = new ZugferdService(); + return $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings); } private function buildHtml(array $inv, array $lines, array $settings): string diff --git a/src/Services/SmtpMailer.php b/src/Services/SmtpMailer.php new file mode 100644 index 0000000..494b136 --- /dev/null +++ b/src/Services/SmtpMailer.php @@ -0,0 +1,237 @@ +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); + } +} diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index 049b723..002fabb 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -31,7 +31,11 @@ $badgeClass = match($invoice['status']) { - PDF herunterladen + PDF herunterladen + + + Zahlungseingang erfassen @@ -311,3 +315,31 @@ $badgeClass = match($invoice['status']) { } }()); + + + diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index 22a0f8e..a02119f 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -256,11 +256,84 @@ + +
+
E-Mail-Versand (SMTP)
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +