hub v0.71.0: paired recovery mails (F11), prefs seeding at claim + empty-email no-clobber (F12), priority headers + operator test leg (F14-light)
This commit is contained in:
@@ -29,8 +29,10 @@ type Dispatcher struct {
|
||||
custCooldowns map[string]time.Time // "customerID:eventType" → last customer notify
|
||||
|
||||
// sendEmailFn is the email sender, seam-injected so tests exercise routing without real HTTP.
|
||||
// Defaults to (*Dispatcher).sendEmail (Resend) in NewDispatcher.
|
||||
sendEmailFn func(to, subject, textBody string) error
|
||||
// Defaults to (*Dispatcher).sendEmail (Resend) in NewDispatcher. headers (nil = none) become
|
||||
// Resend custom headers — used for the high-priority nudge on error/critical mails (v0.71.0,
|
||||
// audit F14-light).
|
||||
sendEmailFn func(to, subject, textBody string, headers map[string]string) error
|
||||
}
|
||||
|
||||
// NewDispatcher creates a new notification dispatcher.
|
||||
@@ -50,6 +52,26 @@ func NewDispatcher(s *store.Store, resendAPIKey, fromEmail, operatorEmail string
|
||||
return d
|
||||
}
|
||||
|
||||
// priorityHeaders returns the Resend custom headers that nudge mail clients toward attention for
|
||||
// error/critical mails (X-Priority + Importance; v0.71.0, audit F14-light: delivered ≠ noticed).
|
||||
// Everything else gets nil — a warning or info mail must NOT masquerade as urgent. Pure → tested.
|
||||
func priorityHeaders(severity string) map[string]string {
|
||||
switch severity {
|
||||
case "error", "critical":
|
||||
return map[string]string{"X-Priority": "1", "Importance": "high"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// recoveredPairedDownTypes maps a *_recovered eventType to the stale/down set whose customer-channel
|
||||
// "sent" evidence licenses the customer recovery mail (v0.71.0, audit F11): recovery notifies
|
||||
// exactly whoever the down notified.
|
||||
var recoveredPairedDownTypes = map[string][]string{
|
||||
"node_recovered": {"node_stale", "node_down"},
|
||||
"host_recovered": {"host_stale", "host_down"},
|
||||
}
|
||||
|
||||
// severityNotifies reports whether a severity triggers email notifications. warning / error / critical
|
||||
// notify; everything else (info, recovery/status, or an unrecognized value) does not. Pure → unit-tested.
|
||||
// (Before v0.24.0 a "critical" severity was silently dropped here — the host_disk-class bug.)
|
||||
@@ -75,6 +97,14 @@ func (d *Dispatcher) ProcessEvent(customerID, eventType, severity, message, deta
|
||||
return
|
||||
}
|
||||
|
||||
// Recovery branch (v0.71.0, audit F11) — BEFORE the severity gate, as an explicit eventType
|
||||
// branch: *_recovered stays severity "info" (semantics frozen), but is no longer silent.
|
||||
// Operator always hears both edges; the customer hears recovery iff they heard the down.
|
||||
if _, isRecovery := recoveredPairedDownTypes[eventType]; isRecovery {
|
||||
d.processRecovery(customerID, eventType, severity, message, detailsJSON, source)
|
||||
return
|
||||
}
|
||||
|
||||
// warning / error / critical trigger notifications. "info" is an intentional non-notify (status/
|
||||
// recovery events). Anything else is UNRECOGNIZED — log it (don't silently drop), so a bad severity
|
||||
// surfaces instead of vanishing (the felhom-pve-class lesson: a critical event must never be lost).
|
||||
@@ -93,22 +123,106 @@ func (d *Dispatcher) ProcessEvent(customerID, eventType, severity, message, deta
|
||||
}
|
||||
|
||||
func (d *Dispatcher) sendTestEmail(customerID string) {
|
||||
// nil-prefs guard (v0.71.0): GetNotificationPrefs returns (nil, nil) for a customer with no
|
||||
// notification row — dereferencing prefs.Email here panicked the dispatcher goroutine for such
|
||||
// a customer (latent since the test leg shipped; found while adding the operator copy).
|
||||
prefs, err := d.store.GetNotificationPrefs(customerID)
|
||||
if err != nil || prefs.Email == "" {
|
||||
if err != nil || prefs == nil || prefs.Email == "" {
|
||||
d.logger.Printf("[WARN] Test email: no email configured for %s", customerID)
|
||||
} else {
|
||||
subject := "[Felhom] Teszt értesítés"
|
||||
body := "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring"
|
||||
|
||||
if err := d.sendEmailFn(prefs.Email, subject, body, nil); err != nil {
|
||||
d.logger.Printf("[ERROR] Test email to %s failed: %v", prefs.Email, err)
|
||||
d.store.LogNotification(customerID, "test", "info", "Teszt értesítés", "failed", err.Error(), "customer")
|
||||
} else {
|
||||
d.logger.Printf("[INFO] Test email sent to %s for %s", prefs.Email, customerID)
|
||||
d.store.LogNotification(customerID, "test", "info", "Teszt értesítés", "sent", "", "customer")
|
||||
}
|
||||
}
|
||||
|
||||
// Operator copy (v0.71.0, audit F14-light): one test click proves the customer channel, the
|
||||
// operator channel AND the high-priority header rendering in a single shot.
|
||||
if !d.operatorOn || d.operatorEmail == "" {
|
||||
return
|
||||
}
|
||||
opSubject := fmt.Sprintf("[Felhom] ✅ %s: teszt / operator channel OK", customerID)
|
||||
opBody := fmt.Sprintf(`Operator copy of the customer notification test for %s.
|
||||
|
||||
If this mail shows as high priority in your client, the X-Priority/Importance
|
||||
headers render correctly. The customer test mail result is recorded in the
|
||||
notification log.
|
||||
|
||||
Dashboard: https://hub.felhom.eu/customers/%s`, customerID, customerID)
|
||||
if err := d.sendEmailFn(d.operatorEmail, opSubject, opBody, priorityHeaders("critical")); err != nil {
|
||||
d.logger.Printf("[ERROR] Operator test email failed for %s: %v", customerID, err)
|
||||
d.store.LogNotification(customerID, "test", "info", "operator test copy", "failed", err.Error(), "operator")
|
||||
return
|
||||
}
|
||||
d.logger.Printf("[INFO] Operator test email sent for %s", customerID)
|
||||
d.store.LogNotification(customerID, "test", "info", "operator test copy", "sent", "", "operator")
|
||||
}
|
||||
|
||||
// processRecovery routes a *_recovered event (v0.71.0, audit F11). Severity semantics stay frozen
|
||||
// ("info" everywhere else remains non-notify) — this is an explicit eventType branch.
|
||||
// - Operator leg: always wanted (both edges), gated only by operatorOn + the 1h per-type
|
||||
// cooldown — exactly processOperator.
|
||||
// - Customer leg: gated by the PAIRING rule, not enabled_events — "recovery notifies exactly
|
||||
// whoever the down notified." Evidence = a customer-channel status=sent row for the paired
|
||||
// stale/down set newer than the last customer-channel sent recovery of this type.
|
||||
func (d *Dispatcher) processRecovery(customerID, eventType, severity, message, detailsJSON, source string) {
|
||||
d.processOperator(customerID, eventType, severity, message, detailsJSON, source)
|
||||
|
||||
if d.store.IsCustomerBlocked(customerID) {
|
||||
return
|
||||
}
|
||||
prefs, err := d.store.GetNotificationPrefs(customerID)
|
||||
if err != nil || prefs == nil || prefs.Email == "" {
|
||||
return
|
||||
}
|
||||
|
||||
subject := "[Felhom] Teszt értesítés"
|
||||
body := "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring"
|
||||
|
||||
if err := d.sendEmailFn(prefs.Email, subject, body); err != nil {
|
||||
d.logger.Printf("[ERROR] Test email to %s failed: %v", prefs.Email, err)
|
||||
d.store.LogNotification(customerID, "test", "info", "Teszt értesítés", "failed", err.Error(), "customer")
|
||||
// Pairing check — the customer gate. enabled_events is deliberately ignored here: a customer
|
||||
// who was told "down" must be told "recovered", and one who wasn't must not be.
|
||||
lastDown, downOk, err := d.store.LastCustomerSentAt(customerID, recoveredPairedDownTypes[eventType])
|
||||
if err != nil {
|
||||
d.logger.Printf("[ERROR] Recovery pairing query failed for %s/%s: %v", customerID, eventType, err)
|
||||
return
|
||||
}
|
||||
d.logger.Printf("[INFO] Test email sent to %s for %s", prefs.Email, customerID)
|
||||
d.store.LogNotification(customerID, "test", "info", "Teszt értesítés", "sent", "", "customer")
|
||||
lastRecovered, recOk, err := d.store.LastCustomerSentAt(customerID, []string{eventType})
|
||||
if err != nil {
|
||||
d.logger.Printf("[ERROR] Recovery pairing query failed for %s/%s: %v", customerID, eventType, err)
|
||||
return
|
||||
}
|
||||
// Second-granularity ties resolve to NOT-after → no mail (flap-safe direction).
|
||||
if !downOk || (recOk && !lastDown.After(lastRecovered)) {
|
||||
d.logger.Printf("[INFO] Recovery %s for %s: customer mail skipped — no unanswered customer down mail (pairing miss)", eventType, customerID)
|
||||
return
|
||||
}
|
||||
|
||||
// Prefs cooldown keyed on the recovered eventType (belt over the pairing braces).
|
||||
cooldownHours := prefs.CooldownHours
|
||||
if cooldownHours <= 0 {
|
||||
cooldownHours = 6
|
||||
}
|
||||
cooldownKey := customerID + ":" + eventType
|
||||
d.mu.Lock()
|
||||
if last, ok := d.custCooldowns[cooldownKey]; ok && time.Since(last) < time.Duration(cooldownHours)*time.Hour {
|
||||
d.mu.Unlock()
|
||||
d.logger.Printf("[INFO] Recovery %s for %s: customer mail skipped — cooldown", eventType, customerID)
|
||||
return
|
||||
}
|
||||
d.custCooldowns[cooldownKey] = time.Now()
|
||||
d.mu.Unlock()
|
||||
|
||||
subject, body := FormatCustomerEmail(customerID, eventType, severity, message, detailsJSON)
|
||||
if err := d.sendEmailFn(prefs.Email, subject, body, priorityHeaders(severity)); err != nil {
|
||||
d.logger.Printf("[ERROR] Customer recovery email failed for %s/%s: %v", customerID, eventType, err)
|
||||
d.store.LogNotification(customerID, eventType, severity, message, "failed", err.Error(), "customer")
|
||||
return
|
||||
}
|
||||
d.logger.Printf("[INFO] Customer recovery email sent to %s for %s/%s", prefs.Email, customerID, eventType)
|
||||
d.store.LogNotification(customerID, eventType, severity, message, "sent", "", "customer")
|
||||
}
|
||||
|
||||
func (d *Dispatcher) processOperator(customerID, eventType, severity, message, detailsJSON, source string) {
|
||||
@@ -127,7 +241,7 @@ func (d *Dispatcher) processOperator(customerID, eventType, severity, message, d
|
||||
|
||||
subject, body := FormatOperatorEmail(customerID, eventType, severity, message, detailsJSON)
|
||||
|
||||
if err := d.sendEmailFn(d.operatorEmail, subject, body); err != nil {
|
||||
if err := d.sendEmailFn(d.operatorEmail, subject, body, priorityHeaders(severity)); err != nil {
|
||||
d.logger.Printf("[ERROR] Operator email failed for %s/%s: %v", customerID, eventType, err)
|
||||
d.store.LogNotification(customerID, eventType, severity, message, "failed", err.Error(), "operator")
|
||||
return
|
||||
@@ -173,7 +287,7 @@ func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, d
|
||||
|
||||
subject, body := FormatCustomerEmail(customerID, eventType, severity, message, detailsJSON)
|
||||
|
||||
if err := d.sendEmailFn(prefs.Email, subject, body); err != nil {
|
||||
if err := d.sendEmailFn(prefs.Email, subject, body, priorityHeaders(severity)); err != nil {
|
||||
d.logger.Printf("[ERROR] Customer email failed for %s/%s: %v", customerID, eventType, err)
|
||||
d.store.LogNotification(customerID, eventType, severity, message, "failed", err.Error(), "customer")
|
||||
return
|
||||
@@ -182,13 +296,16 @@ func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, d
|
||||
d.store.LogNotification(customerID, eventType, severity, message, "sent", "", "customer")
|
||||
}
|
||||
|
||||
func (d *Dispatcher) sendEmail(to, subject, textBody string) error {
|
||||
func (d *Dispatcher) sendEmail(to, subject, textBody string, headers map[string]string) error {
|
||||
payload := map[string]interface{}{
|
||||
"from": d.fromEmail,
|
||||
"to": []string{to},
|
||||
"subject": subject,
|
||||
"text": textBody,
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
payload["headers"] = headers
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -236,7 +353,7 @@ func (d *Dispatcher) SendClaimEmail(kind, customerID, email, domain, code string
|
||||
}
|
||||
subject, body := FormatClaimEmail(kind, customerID, domain, code)
|
||||
eventType := "claim_" + kind
|
||||
if err := d.sendEmailFn(email, subject, body); err != nil {
|
||||
if err := d.sendEmailFn(email, subject, body, nil); err != nil {
|
||||
d.logger.Printf("[ERROR] claim %s email to customer %s failed: %v", kind, customerID, err)
|
||||
d.store.LogNotification(customerID, eventType, "info", subject, "failed", err.Error(), "customer")
|
||||
return err
|
||||
@@ -257,7 +374,7 @@ func (d *Dispatcher) SendSelfBindEmail(customerID, email, link string) error {
|
||||
return fmt.Errorf("notify: no resend api key")
|
||||
}
|
||||
subject, body := FormatSelfBindEmail(customerID, link)
|
||||
if err := d.sendEmailFn(email, subject, body); err != nil {
|
||||
if err := d.sendEmailFn(email, subject, body, nil); err != nil {
|
||||
d.logger.Printf("[ERROR] self-bind link email to customer %s failed: %v", customerID, err)
|
||||
d.store.LogNotification(customerID, "selfbind_link", "info", subject, "failed", err.Error(), "customer")
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user