Merge pull request 'Feature: Rechnungsversand per E-Mail (eigener SMTP-Client)' (#62) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/62
This commit is contained in:
commit
586d39f260
16 changed files with 680 additions and 60 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;
|
||||
3
migrations/040_invoice_status_finalisiert.sql
Normal file
3
migrations/040_invoice_status_finalisiert.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE invoices
|
||||
MODIFY COLUMN status ENUM('Entwurf','Finalisiert','Gesendet','Bezahlt','Storniert')
|
||||
NOT NULL DEFAULT 'Entwurf';
|
||||
3
migrations/041_mail_templates.sql
Normal file
3
migrations/041_mail_templates.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE company_settings
|
||||
ADD COLUMN IF NOT EXISTS mail_subject_template VARCHAR(500) NOT NULL DEFAULT 'Rechnung %RNR%' AFTER mail_from_email,
|
||||
ADD COLUMN IF NOT EXISTS mail_body_template TEXT AFTER mail_subject_template;
|
||||
|
|
@ -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,8 @@ $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-template', fn($p) => (new SettingsController())->mailTemplate($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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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,44 @@ class SettingsController
|
|||
View::redirect('/settings', 'Logo hochgeladen.');
|
||||
}
|
||||
|
||||
public function mailTemplate(array $params): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
$subject = trim($_POST['mail_subject_template'] ?? '');
|
||||
$body = trim($_POST['mail_body_template'] ?? '');
|
||||
|
||||
if ($subject === '') {
|
||||
echo json_encode(['error' => 'Betreff darf nicht leer sein.']);
|
||||
return;
|
||||
}
|
||||
|
||||
\Steuerkram\Database::execute(
|
||||
'UPDATE company_settings SET mail_subject_template=?, mail_body_template=? WHERE id=1',
|
||||
[$subject, $body ?: null]
|
||||
);
|
||||
|
||||
echo json_encode(['ok' => true]);
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ class Invoice
|
|||
$fy = FiscalYear::findOrCreateByYear($year);
|
||||
|
||||
Database::execute(
|
||||
'UPDATE invoices SET rechnungs_nr=?, status=\'Gesendet\', fiscal_year_id=?, updated_at=NOW() WHERE id=?',
|
||||
'UPDATE invoices SET rechnungs_nr=?, status=\'Finalisiert\', fiscal_year_id=?, updated_at=NOW() WHERE id=?',
|
||||
[$nr, $fy['id'], $id]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ 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=?,
|
||||
mail_subject_template=?, mail_body_template=?,
|
||||
logo_max_width=?, logo_max_height=?,
|
||||
logo_position=?, logo_margin_side=?, logo_margin_top=?
|
||||
WHERE id=1',
|
||||
|
|
@ -68,6 +72,15 @@ 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'] ?: '',
|
||||
$data['mail_subject_template'] ?: 'Rechnung %RNR%',
|
||||
$data['mail_body_template'] ?: null,
|
||||
max(50, (int)($data['logo_max_width'] ?? 600)),
|
||||
max(20, (int)($data['logo_max_height'] ?? 100)),
|
||||
$logoPosition,
|
||||
|
|
|
|||
81
src/Services/EmailService.php
Normal file
81
src/Services/EmailService.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?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';
|
||||
$filename = 'Rechnung-' . $nr . '.pdf';
|
||||
|
||||
$defaultBody = "Guten Tag,\n\n"
|
||||
. "anbei erhalten Sie die Rechnung %RNR% von %FNAME%.\n\n"
|
||||
. "Bei Fragen stehen wir Ihnen gerne zur Verfügung.\n\n"
|
||||
. "Mit freundlichen Grüßen\n"
|
||||
. "%INHABER%";
|
||||
|
||||
$subjectTpl = ($settings['mail_subject_template'] ?? '') ?: 'Rechnung %RNR%';
|
||||
$bodyTpl = ($settings['mail_body_template'] ?? '') ?: $defaultBody;
|
||||
|
||||
$subject = $this->replacePlaceholders($subjectTpl, $invoice, $settings);
|
||||
$body = $this->replacePlaceholders($bodyTpl, $invoice, $settings);
|
||||
|
||||
$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 replacePlaceholders(string $text, array $invoice, array $settings): string
|
||||
{
|
||||
return str_replace(
|
||||
['%RNR%', '%FNAME%', '%INHABER%'],
|
||||
[$invoice['rechnungs_nr'] ?? 'Entwurf', $settings['firma'] ?? '', $settings['inhaber'] ?? ''],
|
||||
$text
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,10 +10,11 @@ $customerName = !empty($invoice['firma'])
|
|||
$lzHtml = $h($invoice['leistungszeitraum'] ?? '');
|
||||
|
||||
$badgeClass = match($invoice['status']) {
|
||||
'Bezahlt' => 'badge-green',
|
||||
'Storniert' => 'badge-red',
|
||||
'Gesendet' => 'badge-yellow',
|
||||
default => 'badge-gray',
|
||||
'Bezahlt' => 'badge-green',
|
||||
'Storniert' => 'badge-red',
|
||||
'Gesendet' => 'badge-yellow',
|
||||
'Finalisiert' => 'badge-blue',
|
||||
default => 'badge-gray',
|
||||
};
|
||||
?>
|
||||
|
||||
|
|
@ -31,12 +32,16 @@ $badgeClass = match($invoice['status']) {
|
|||
</form>
|
||||
<?php endif; ?>
|
||||
<?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 if ($invoice['status'] === 'Gesendet'): ?>
|
||||
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
|
||||
<?php endif; ?>
|
||||
<?php if (in_array($invoice['status'], ['Entwurf','Gesendet'])): ?>
|
||||
<?php if (in_array($invoice['status'], ['Entwurf','Finalisiert','Gesendet'])): ?>
|
||||
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
|
||||
data-confirm="Rechnung stornieren?">
|
||||
<button type="submit" class="btn btn-danger">Stornieren</button>
|
||||
|
|
@ -311,3 +316,31 @@ $badgeClass = match($invoice['status']) {
|
|||
}
|
||||
}());
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||
|
||||
<form id="settings-form" method="post" action="/settings">
|
||||
<input type="hidden" id="hf-mail-subject" name="mail_subject_template"
|
||||
value="<?= $h($settings['mail_subject_template'] ?? 'Rechnung %RNR%') ?>">
|
||||
<input type="hidden" id="hf-mail-body" name="mail_body_template"
|
||||
value="<?= $h($settings['mail_body_template'] ?? '') ?>">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
|
||||
|
||||
<!-- Firmendaten -->
|
||||
|
|
@ -81,9 +85,55 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><div class="card-title">Bankdaten</div></div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label>Bankname</label>
|
||||
<input type="text" name="bank_name" value="<?= $h($settings['bank_name'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>IBAN</label>
|
||||
<input type="text" name="iban" value="<?= $h($settings['iban'] ?? '') ?>" placeholder="DE89 3704 0044 0532 0130 00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>BIC</label>
|
||||
<input type="text" name="bic" value="<?= $h($settings['bic'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><div class="card-title">Nummernkreise</div></div>
|
||||
<div class="card-body">
|
||||
<div class="form-grid form-grid-2">
|
||||
<div class="form-group">
|
||||
<label>Rechnungs-Präfix</label>
|
||||
<input type="text" name="invoice_nr_prefix" value="<?= $h($settings['invoice_nr_prefix'] ?? 'AR') ?>" maxlength="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Beleg-Präfix</label>
|
||||
<input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Anlagen-Präfix</label>
|
||||
<input type="text" name="anlage_nr_prefix" value="<?= $h($settings['anlage_nr_prefix'] ?? 'ANL') ?>" maxlength="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kunden-Präfix</label>
|
||||
<input type="text" name="customer_nr_prefix" value="<?= $h($settings['customer_nr_prefix'] ?? 'K-') ?>" maxlength="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Standard-Zahlungsziel (Tage)</label>
|
||||
<input type="number" name="payment_days_default" value="<?= $h($settings['payment_days_default'] ?? 14) ?>" min="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bankdaten + Logo + Nummernkreise -->
|
||||
<!-- rechte Spalte -->
|
||||
<div>
|
||||
<!-- Betriebsmodus -->
|
||||
<div class="card mb-2" style="border:2px solid <?= ($settings['test_mode'] ?? 1) ? 'var(--color-warning,#f59e0b)' : 'var(--color-success,#22c55e)' ?>">
|
||||
|
|
@ -210,60 +260,57 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- E-Mail-Versand -->
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><div class="card-title">Bankdaten</div></div>
|
||||
<div class="card-header"><div class="card-title">E-Mail-Versand (SMTP)</div></div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label>Bankname</label>
|
||||
<input type="text" name="bank_name" value="<?= $h($settings['bank_name'] ?? '') ?>">
|
||||
<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-group">
|
||||
<label>IBAN</label>
|
||||
<input type="text" name="iban" value="<?= $h($settings['iban'] ?? '') ?>" placeholder="DE89 3704 0044 0532 0130 00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>BIC</label>
|
||||
<input type="text" name="bic" value="<?= $h($settings['bic'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><div class="card-title">Nummernkreise</div></div>
|
||||
<div class="card-body">
|
||||
<div class="form-grid form-grid-2">
|
||||
<div class="form-group">
|
||||
<label>Rechnungs-Präfix</label>
|
||||
<input type="text" name="invoice_nr_prefix" value="<?= $h($settings['invoice_nr_prefix'] ?? 'AR') ?>" maxlength="10">
|
||||
<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>Beleg-Präfix</label>
|
||||
<input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Anlagen-Präfix</label>
|
||||
<input type="text" name="anlage_nr_prefix" value="<?= $h($settings['anlage_nr_prefix'] ?? 'ANL') ?>" maxlength="10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kunden-Präfix</label>
|
||||
<input type="text" name="customer_nr_prefix" value="<?= $h($settings['customer_nr_prefix'] ?? 'K-') ?>" maxlength="10">
|
||||
<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-group">
|
||||
<label>Standard-Zahlungsziel (Tage)</label>
|
||||
<input type="number" name="payment_days_default" value="<?= $h($settings['payment_days_default'] ?? 14) ?>" min="0">
|
||||
<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>
|
||||
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" id="btn-mail-text">Text bearbeiten</button>
|
||||
<button type="button" class="btn btn-secondary" id="btn-mail-test">Test-Mail senden</button>
|
||||
<span id="mail-test-result" style="font-size:.8rem"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:0">
|
||||
<div></div>
|
||||
<div>
|
||||
<!-- Logo -->
|
||||
<div class="card mb-2">
|
||||
<div class="card-header"><div class="card-title">Logo</div></div>
|
||||
<div class="card-body">
|
||||
|
|
@ -334,9 +381,61 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Modal: E-Mail-Text bearbeiten -->
|
||||
<div id="mail-text-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:520px;max-width:95vw;box-shadow:0 8px 32px rgba(0,0,0,.2)">
|
||||
<h3 style="margin:0 0 1rem">E-Mail-Text bearbeiten</h3>
|
||||
<p style="font-size:.78rem;color:#666;margin:0 0 1rem">
|
||||
Platzhalter: <code>%RNR%</code> = Rechnungsnummer · <code>%FNAME%</code> = Firmenname · <code>%INHABER%</code> = Inhaber
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label>Betreff</label>
|
||||
<input type="text" id="mail-text-subject" style="width:100%"
|
||||
value="<?= $h($settings['mail_subject_template'] ?? 'Rechnung %RNR%') ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Text</label>
|
||||
<textarea id="mail-text-body" rows="8" style="width:100%;resize:vertical;font-family:inherit;font-size:.85rem"><?= $h($settings['mail_body_template'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.25rem">
|
||||
<button type="button" class="btn btn-secondary" id="btn-mail-text-cancel">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-mail-text-save">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<script>
|
||||
function updateModeLabel(cb) {
|
||||
const track = document.getElementById('toggle_track');
|
||||
|
|
@ -369,4 +468,50 @@ function logoPositionChanged() {
|
|||
document.getElementById('logo-margin-fields').style.display =
|
||||
(pos === 'left' || pos === 'right') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// E-Mail-Text-Modal
|
||||
document.getElementById('btn-mail-text')?.addEventListener('click', function () {
|
||||
document.getElementById('mail-text-modal').style.display = 'flex';
|
||||
});
|
||||
document.getElementById('btn-mail-text-cancel')?.addEventListener('click', function () {
|
||||
document.getElementById('mail-text-modal').style.display = 'none';
|
||||
});
|
||||
document.getElementById('btn-mail-text-save')?.addEventListener('click', function () {
|
||||
var btn = this;
|
||||
var subject = document.getElementById('mail-text-subject').value.trim();
|
||||
var body = document.getElementById('mail-text-body').value;
|
||||
var result = document.getElementById('mail-test-result');
|
||||
|
||||
if (!subject) {
|
||||
document.getElementById('mail-text-subject').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
var fd = new FormData();
|
||||
fd.append('mail_subject_template', subject);
|
||||
fd.append('mail_body_template', body);
|
||||
|
||||
fetch('/settings/mail-template', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.ok) {
|
||||
// Versteckte Felder im Hauptformular synchron halten
|
||||
document.getElementById('hf-mail-subject').value = subject;
|
||||
document.getElementById('hf-mail-body').value = body;
|
||||
document.getElementById('mail-text-modal').style.display = 'none';
|
||||
result.textContent = '✓ E-Mail-Text gespeichert.';
|
||||
result.style.color = 'green';
|
||||
setTimeout(function () { result.textContent = ''; }, 3000);
|
||||
} else {
|
||||
result.textContent = '✗ ' + (data.error || 'Fehler beim Speichern.');
|
||||
result.style.color = 'red';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
result.textContent = '✗ Netzwerkfehler.';
|
||||
result.style.color = 'red';
|
||||
})
|
||||
.finally(function () { btn.disabled = false; });
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class InvoiceBuchungTest extends TestCase
|
|||
$this->assertNotEmpty($nr, 'Rechnung wurde finalisiert (Rechnungsnummer erhalten)');
|
||||
|
||||
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
|
||||
$this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Rechnungsstatus ist Gesendet');
|
||||
$this->assertEquals('Finalisiert', $invoice['status'] ?? null, 'Rechnungsstatus ist Finalisiert');
|
||||
|
||||
// Buchungen erzeugen (EÜR: Einnahme)
|
||||
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class InvoiceToReportsTest extends TestCase
|
|||
$this->assertNotEmpty($rechnungsNr, 'Rechnungsnummer nach Finalisierung vorhanden');
|
||||
|
||||
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
|
||||
$this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Status nach Finalisierung: Gesendet');
|
||||
$this->assertEquals('Finalisiert', $invoice['status'] ?? null, 'Status nach Finalisierung: Finalisiert');
|
||||
|
||||
// ── Beleg und Buchungen anlegen (wie InvoiceController::createBelegAndBookings) ──
|
||||
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ class IstVersteuerungTest extends TestCase
|
|||
$this->assertNotEmpty($nr, 'Rechnungsnummer vergeben');
|
||||
|
||||
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
|
||||
$this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Status nach Finalisierung: Gesendet');
|
||||
$this->assertEquals('Finalisiert', $invoice['status'] ?? null, 'Status nach Finalisierung: Finalisiert');
|
||||
|
||||
// Beleg anlegen (wie in InvoiceController::createBeleg)
|
||||
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue