Files
warmbly/internal/app/sysstatus/sysstatus.go
T
Matthew Meszaros 47defafa09 feat: fix the six self-host defects reported in issue #439 (#456)
* feat: fix the six defects reported in issue #439 by mapping the IMAP UNAVAILABLE, INUSE and NONEXISTENT response codes to retry-level errors instead of a critical reconnect prompt, synthesising a stable no-msgid key so one message with no Message-ID header can no longer 400 the internal map endpoint and wedge every later sync pass with its cursors held, adding mailhtml.FromText and HasContent so an API or agent-created step with a plain body stops shipping the composer's empty div placeholder as its text/html part (derived on create and plain-only update, exposed as body_html on update_campaign_step, dropped at send and preview time, and refused at campaign start with empty_step_body), honouring sender_strategy='explicit' in ResolveCampaignSenderPool and ValidateCampaignReady so an emptied explicit pool parks the campaign instead of widening it to every mailbox in the workspace, making the paused_no_accounts auto-pause loud with an error log line, an error-level activity-feed entry and an org-scoped CAMPAIGN_PAUSED realtime pulse, gating the admin sign-in's Turnstile widget on GET /v1/auth/config so a self-host with CAPTCHA_PROVIDER=none is not locked out, and parsing NATS_URL down to its host:port so a credentialed bus URL no longer reports NATS down

* feat: act on the self-review of the issue #439 fixes by dropping the campaign wizard's own escapeHtml body_html builder, which entity-escaped the quotes in a conditional and made the template fail to parse at send time, and letting the backend's FromText render that part instead so wizard-written steps also get their bare URLs linked for click tracking, correcting the docs and openapi description that claimed an explicit sender pool never falls back when it still unions its tags as migration 000013 designed, extracting the duplicated blank-HTML-part guard into dropBlankHTMLPart shared by the send path and the preview, and recording why the no-msgid key keeps the folder name despite a RENAME changing it

* feat: address the CodeRabbit review on the issue #439 fixes by holding the admin sign-in's Turnstile widget unmounted until /v1/auth/config resolves so an instance with no route to Cloudflare cannot raise a widget error on a screen nobody submitted, failing StartCampaign closed when the sequence read errors rather than skipping both the malformed-template and empty-body refusals, giving TCPCheck the default port its protocol assumes so a portless NATS_URL is no longer reported down, leaving a URL that carries a merge field unanchored because the send path renders bodies with text/template and a quoted contact value would break out of the href, and correcting the sequences guide and the Campaign and CampaignUpdate openapi descriptions that named the wrong tag field
2026-09-12 03:13:38 -07:00

157 lines
4.5 KiB
Go

// Package sysstatus runs cheap liveness probes against the platform's backing
// services (Postgres, Redis, Kafka, schema registry, realtime, ...) so the
// admin dashboard can show component health at a glance. Checks are wired as
// closures in cmd/backend/main.go where the concrete clients live; this
// package only knows how to run them in parallel with a bounded timeout.
package sysstatus
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
)
// perCheckTimeout bounds each probe so one dead dependency can't stall the
// whole status response.
const perCheckTimeout = 3 * time.Second
type check struct {
name string
fn func(ctx context.Context) error
}
// Checker holds the registered probes.
type Checker struct {
checks []check
}
// New creates an empty checker.
func New() *Checker {
return &Checker{}
}
// Add registers a named probe. Nil funcs are ignored.
func (c *Checker) Add(name string, fn func(ctx context.Context) error) {
if fn == nil {
return
}
c.checks = append(c.checks, check{name: name, fn: fn})
}
// Result is one probe outcome.
type Result struct {
Name string `json:"name"`
OK bool `json:"ok"`
LatencyMS int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
// Run executes every probe in parallel and returns results in registration
// order.
func (c *Checker) Run(ctx context.Context) []Result {
results := make([]Result, len(c.checks))
var wg sync.WaitGroup
for i, ch := range c.checks {
wg.Add(1)
go func(i int, ch check) {
defer wg.Done()
cctx, cancel := context.WithTimeout(ctx, perCheckTimeout)
defer cancel()
start := time.Now()
err := ch.fn(cctx)
r := Result{Name: ch.name, OK: err == nil, LatencyMS: time.Since(start).Milliseconds()}
if err != nil {
r.Error = err.Error()
}
results[i] = r
}(i, ch)
}
wg.Wait()
return results
}
// HTTPCheck probes a URL and treats any response below 500 as healthy (auth
// walls still prove the service is up).
func HTTPCheck(url string) func(ctx context.Context) error {
return func(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("status %d", resp.StatusCode)
}
return nil
}
}
// TCPCheck probes the first address of a comma-separated list. Each entry may
// be a bare host:port or a full URL, because the variables these come from
// (NATS_URL, the broker list) are connection strings, not dial addresses.
//
// defaultPort is the port the client library assumes when the URL names none,
// and it has to be supplied because that is protocol knowledge this package
// does not have. Without it a perfectly good NATS_URL of "nats://host" is
// reported down: the client connects on 4222 and net.Dial refuses an address
// with no port at all.
func TCPCheck(addrs, defaultPort string) func(ctx context.Context) error {
addr := withPort(DialAddr(strings.Split(addrs, ",")[0]), defaultPort)
return func(ctx context.Context) error {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return err
}
return conn.Close()
}
}
// DialAddr reduces a connection string to the host:port net.Dial accepts.
// Credentials in the authority are the reason this exists: net.Dial reads
// "user:pass@host:4222" as an address with too many colons, so a credentialed
// NATS_URL reported the bus down while everything worked.
func DialAddr(raw string) string {
addr := strings.TrimSpace(raw)
if i := strings.Index(addr, "://"); i >= 0 {
addr = addr[i+3:]
}
// Last "@": a password may legitimately contain one.
if i := strings.LastIndex(addr, "@"); i >= 0 {
addr = addr[i+1:]
}
// Anything after the authority (path, query, fragment) is not dialled.
if i := strings.IndexAny(addr, "/?#"); i >= 0 {
addr = addr[:i]
}
return addr
}
// withPort appends the caller's default port to an address that names none.
// IPv6 literals are left alone unless they are bracketed, since "::1" is all
// colons and guessing where the port would go is how this gets worse.
func withPort(addr, defaultPort string) string {
if addr == "" || defaultPort == "" {
return addr
}
if strings.HasPrefix(addr, "[") {
// [::1]:4222 has a port, [::1] does not.
if strings.Contains(addr[strings.Index(addr, "]"):], ":") {
return addr
}
return addr + ":" + defaultPort
}
if strings.Contains(addr, ":") {
return addr
}
return addr + ":" + defaultPort
}