internal/channels: konkrete Zustellkanaele fuer CFG-02s Dispatcher.
TemplateStore.Resolve loest Vorlagen pro Tenant auf und faellt auf
GlobalTemplateScope zurueck, wenn ein Tenant keine eigene gesetzt hat
(Akzeptanzkriterium 3). Render nutzt text/template mit
Option("missingkey=error") — ein fehlender Platzhalter bricht das Rendering
MIT FEHLER ab, statt eine unvollstaendige Nachricht zu erzeugen
(Akzeptanzkriterium 1).
EmailSender implementiert notify.Sender: rendert ZUERST die Vorlage, bevor
ueberhaupt eine SMTP-Verbindung aufgebaut wird — schlaegt das Rendering
fehl, wird nie ein Netzwerkzugriff versucht. Ein anschliessend fehl-
schlagender SMTP-Versand liefert einen Fehler, den CFG-02s bereits
getestete Wiederholungslogik verarbeitet (kein zweiter Retry-Mechanismus
hier). InAppSender persistiert In-App-Nachrichten ueber InAppStore
(Akzeptanzkriterium 2, ueber API abrufbar/als gelesen markierbar). Router
waehlt den Kanal anhand Notification.Channel — ein neuer Kanal wird per
Register() ergaenzt, ohne Dispatcher oder Router umzubauen.
Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Vorlagenrendering mit fehlenden Platzhaltern bricht kontrolliert ab —
TestRender_MissingPlaceholderAborts und
TestEmailSender_AbortsBeforeSMTPWhenTemplateMissing (Fehler kommt von der
Vorlagenaufloesung, kein SMTP-Verbindungsversuch). PASS.
2. In-App-Benachrichtigung nach Markierung als gelesen korrekt gefuehrt —
TestInAppStore_MarkReadIsReflectedCorrectly. PASS.
3. E-Mail-Versand bei nicht erreichbarem SMTP-Server loest dokumentiertes
Retry-Verhalten ueber CFG-02 aus —
TestEmailSender_TriggersDispatcherRetryOnUnreachableSMTP: echter
EmailSender gegen unerreichbaren Host, ueber notify.Dispatcher
eingereiht, nach ausgeschoepften Wiederholungen status=failed mit
korrekter Versuchszahl. PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
82 lines
2.6 KiB
Go
82 lines
2.6 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/smtp"
|
|
"os"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/notify"
|
|
)
|
|
|
|
// EmailSender implementiert notify.Sender fuer den E-Mail-Kanal
|
|
// (Akzeptanzkriterium 1). SMTP-Zugangsdaten kommen ausschliesslich aus
|
|
// Umgebungsvariablen, nie aus Code/DB.
|
|
type EmailSender struct {
|
|
templates *TemplateStore
|
|
host string
|
|
port string
|
|
from string
|
|
username string
|
|
password string
|
|
}
|
|
|
|
// NewEmailSenderFromEnv liest NEXARCH_SMTP_HOST/PORT/FROM (Pflicht) sowie
|
|
// optional NEXARCH_SMTP_USER/PASSWORD.
|
|
func NewEmailSenderFromEnv(templates *TemplateStore) (*EmailSender, error) {
|
|
host := os.Getenv("NEXARCH_SMTP_HOST")
|
|
port := os.Getenv("NEXARCH_SMTP_PORT")
|
|
from := os.Getenv("NEXARCH_SMTP_FROM")
|
|
if host == "" || port == "" || from == "" {
|
|
return nil, fmt.Errorf("channels: NEXARCH_SMTP_HOST/PORT/FROM muessen gesetzt sein")
|
|
}
|
|
return &EmailSender{
|
|
templates: templates,
|
|
host: host,
|
|
port: port,
|
|
from: from,
|
|
username: os.Getenv("NEXARCH_SMTP_USER"),
|
|
password: os.Getenv("NEXARCH_SMTP_PASSWORD"),
|
|
}, nil
|
|
}
|
|
|
|
// NewEmailSender erlaubt Tests, Host/Port explizit zu setzen (z.B. einen
|
|
// absichtlich nicht erreichbaren Host fuer den Retry-Nachweis), ohne
|
|
// Umgebungsvariablen zu benoetigen.
|
|
func NewEmailSender(templates *TemplateStore, host, port, from string) *EmailSender {
|
|
return &EmailSender{templates: templates, host: host, port: port, from: from}
|
|
}
|
|
|
|
// Send rendert zuerst die Vorlage — schlaegt das fehl (z.B. fehlender
|
|
// Platzhalter), wird NIE eine SMTP-Verbindung aufgebaut
|
|
// (Akzeptanzkriterium 1 / Pruefung 1: kontrollierter Abbruch statt
|
|
// fehlerhafter Mail). Ein danach fehlschlagender SMTP-Versand liefert einen
|
|
// Fehler zurueck, den CFG-02s Dispatcher fuer die bereits getestete
|
|
// Wiederholungslogik nutzt (Akzeptanzkriterium 3 / Pruefung 3) — kein
|
|
// zweiter Retry-Mechanismus hier.
|
|
func (e *EmailSender) Send(ctx context.Context, n notify.Notification) error {
|
|
templateKey, _ := n.Payload["template_key"].(string)
|
|
tenantSlug, _ := n.Payload["tenant_slug"].(string)
|
|
|
|
tmpl, err := e.templates.Resolve(ctx, tenantSlug, templateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("vorlage aufloesen: %w", err)
|
|
}
|
|
subject, body, err := Render(tmpl, n.Payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
msg := []byte("Subject: " + subject + "\r\n\r\n" + body)
|
|
|
|
var auth smtp.Auth
|
|
if e.username != "" {
|
|
auth = smtp.PlainAuth("", e.username, e.password, e.host)
|
|
}
|
|
addr := e.host + ":" + e.port
|
|
if err := smtp.SendMail(addr, auth, e.from, []string{n.Recipient}, msg); err != nil {
|
|
return fmt.Errorf("smtp-versand fehlgeschlagen: %w", err)
|
|
}
|
|
return nil
|
|
}
|