commit 3264e547232c2d495f5c24e26d17dd7bdc469f10 Author: Pierre-Olivier Mercier Date: Mon Apr 27 01:07:57 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9481b99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-happydeliver +checker-happydeliver.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..04c565a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-happydeliver . + +FROM scratch +COPY --from=builder /checker-happydeliver /checker-happydeliver +USER 65534:65534 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["/checker-happydeliver", "-healthcheck"] +ENTRYPOINT ["/checker-happydeliver"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..935c566 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-happydeliver +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker test clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +test: + go test -tags standalone ./... + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2c6360d --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +checker-happydeliver +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + + This product includes software developed as part of the happyDomain + project (https://happydomain.org). + + Portions of this code were originally written for the happyDomain + server (licensed under AGPL-3.0 and a commercial license) and are + made available there under the Apache License, Version 2.0 to enable + a permissively licensed ecosystem of checker plugins. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..637de63 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# checker-happydeliver + +happyDomain checker that drives a [happyDeliver](https://git.nemunai.re/happyDomain/happyDeliver) +instance to assess outbound email deliverability for a domain. + +The checker: + +1. Allocates a fresh recipient address on a happyDeliver instance + (`POST /api/test`). +2. Sends a real test message **from the tested domain** to that address, + using SMTP credentials supplied by the user. +3. Polls happyDeliver until the message is analysed. +4. Stores happyDeliver's report verbatim as the observation, exposes + per-section scores as metrics, and emits one rule per section + (DNS, authentication, spam, blacklist, header, content, plus overall) + that fires CRIT when the score drops under a user-configured minimum. + +## Options + +### Admin + +| Id | Type | Description | +|----------------------|--------|-------------| +| `happydeliver_url` | string | Default base URL of the happyDeliver API. | +| `happydeliver_token` | secret | Default bearer token for the happyDeliver API. | + +### User / per-domain + +| Id | Type | Default | Description | +|----------------------|--------|-------------|-------------| +| `happydeliver_url` | string | (admin) | Override the happyDeliver URL. | +| `happydeliver_token` | secret | (admin) | Override the happyDeliver token. | +| `smtp_host` | string | (none) | Submission server. **Required.** | +| `smtp_port` | number | `587` | Submission port. | +| `smtp_username` | string | (none) | SMTP username (omit for anonymous submission). | +| `smtp_password` | secret | (none) | SMTP password. | +| `smtp_tls` | choice | `starttls` | `starttls`, `tls`, or `none`. | +| `from_address` | string | (none) | From address. **Required.** | +| `subject_override` | string | (default) | Override the test subject. | +| `body_text_override` | text | (default) | Override the plain-text body. | +| `body_html_override` | text | (default) | Override the HTML body. | +| `wait_timeout` | number | `900` | Seconds to wait for analysis. | +| `poll_interval` | number | `5` | Seconds between polls (clamped to [2, 60]). | +| `min_score_
`| number | per-section | Minimum acceptable score for each section. | + +### Per-rule minimum scores + +Defaults: `overall=70`, `dns=70`, `authentication=80`, `spam=70`, +`blacklist=90`, `header=70`, `content=60`. Each can be customised through +the rule options shown in the happyDomain UI. + +## Metrics + +One `happydeliver_score` metric per section, labelled with `section=` +(values: `overall`, `dns`, `authentication`, `spam`, `blacklist`, `header`, +`content`). + +## Build + +```sh +make # standalone binary (HTTP server on :8080) +make plugin # plugin.so loadable by happyDomain +make docker # container image +``` diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..d675434 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,390 @@ +package checker + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net" + "net/http" + "net/mail" + "net/smtp" + "net/url" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +const ( + defaultSubject = "happyDomain deliverability test" + defaultBodyText = "This is an automated deliverability test sent by happyDomain via happyDeliver. You can ignore this message.\r\n" + defaultBodyHTML = `

This is an automated deliverability test sent by happyDomain via happyDeliver.

You can ignore this message.

` +) + +func (p *happyDeliverProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + cfg, err := loadConfig(opts) + if err != nil { + return nil, err + } + + data := &HappyDeliverData{ + Phase: "allocate", + Endpoint: cfg.HappyDeliverURL, + StartedAt: time.Now().UTC(), + } + + client := &http.Client{Timeout: 30 * time.Second} + + test, err := allocateTest(ctx, client, cfg) + if err != nil { + data.Error = err.Error() + return data, nil + } + data.TestID = test.ID + data.RecipientEmail = test.Email + + data.Phase = "send" + if err := sendTestEmail(ctx, cfg, test.Email); err != nil { + data.Error = fmt.Sprintf("send: %v", err) + return data, nil + } + + data.Phase = "wait" + if err := waitForAnalysis(ctx, client, cfg, test.ID); err != nil { + if errors.Is(err, errTimeout) { + data.Phase = "timeout" + data.Error = "happyDeliver did not analyse the message before the timeout" + return data, nil + } + data.Error = fmt.Sprintf("wait: %v", err) + return data, nil + } + + data.Phase = "fetch" + raw, err := fetchReport(ctx, client, cfg, test.ID) + if err != nil { + data.Error = fmt.Sprintf("fetch: %v", err) + return data, nil + } + data.Report = raw + data.AnalysedAt = time.Now().UTC() + data.LatencySeconds = data.AnalysedAt.Sub(data.StartedAt).Seconds() + + scores, grades, err := extractScores(raw) + if err != nil { + data.Phase = "parse" + data.Error = fmt.Sprintf("parse: %v", err) + return data, nil + } + data.Scores = scores + data.Grades = grades + data.Phase = "ok" + return data, nil +} + +type runConfig struct { + HappyDeliverURL string + HappyDeliverToken string + SMTPHost string + SMTPPort int + SMTPUsername string + SMTPPassword string + SMTPTLS string + FromAddress string + FromHeader string + Subject string + BodyText string + BodyHTML string + WaitTimeout time.Duration + PollInterval time.Duration +} + +func loadConfig(opts sdk.CheckerOptions) (*runConfig, error) { + cfg := &runConfig{ + HappyDeliverURL: strings.TrimSpace(stringOpt(opts, "happydeliver_url")), + HappyDeliverToken: stringOpt(opts, "happydeliver_token"), + SMTPHost: stringOpt(opts, "smtp_host"), + SMTPPort: sdk.GetIntOption(opts, "smtp_port", 587), + SMTPUsername: stringOpt(opts, "smtp_username"), + SMTPPassword: stringOpt(opts, "smtp_password"), + SMTPTLS: strings.ToLower(stringOpt(opts, "smtp_tls")), + FromAddress: stringOpt(opts, "from_address"), + Subject: stringOpt(opts, "subject_override"), + BodyText: stringOpt(opts, "body_text_override"), + BodyHTML: stringOpt(opts, "body_html_override"), + } + if cfg.SMTPTLS == "" { + cfg.SMTPTLS = "starttls" + } + if cfg.Subject == "" { + cfg.Subject = defaultSubject + } + if cfg.BodyText == "" { + cfg.BodyText = defaultBodyText + } + if cfg.BodyHTML == "" { + cfg.BodyHTML = defaultBodyHTML + } + cfg.WaitTimeout = time.Duration(sdk.GetIntOption(opts, "wait_timeout", 900)) * time.Second + poll := sdk.GetIntOption(opts, "poll_interval", 5) + if poll < 2 { + poll = 2 + } + if poll > 60 { + poll = 60 + } + cfg.PollInterval = time.Duration(poll) * time.Second + + if cfg.HappyDeliverURL == "" { + return nil, fmt.Errorf("happydeliver_url is required") + } + u, err := url.ParseRequestURI(cfg.HappyDeliverURL) + if err != nil { + return nil, fmt.Errorf("invalid happydeliver_url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("happydeliver_url must use http or https, got %q", u.Scheme) + } + if u.Host == "" { + return nil, fmt.Errorf("happydeliver_url is missing a host") + } + if cfg.SMTPHost == "" { + return nil, fmt.Errorf("smtp_host is required") + } + if cfg.FromAddress == "" { + return nil, fmt.Errorf("from_address is required") + } + parsedFrom, err := mail.ParseAddress(cfg.FromAddress) + if err != nil { + return nil, fmt.Errorf("invalid from_address: %w", err) + } + cfg.FromAddress = parsedFrom.Address + cfg.FromHeader = parsedFrom.String() + switch cfg.SMTPTLS { + case "none", "starttls", "tls": + default: + return nil, fmt.Errorf("smtp_tls must be one of none, starttls, tls") + } + return cfg, nil +} + +func stringOpt(opts sdk.CheckerOptions, key string) string { + v, _ := sdk.GetOption[string](opts, key) + return v +} + +type testResponse struct { + ID string `json:"id"` + Email string `json:"email"` +} + +func allocateTest(ctx context.Context, client *http.Client, cfg *runConfig) (*testResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(cfg.HappyDeliverURL, "/api/test"), nil) + if err != nil { + return nil, err + } + addAuth(req, cfg) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("POST /api/test returned %s", resp.Status) + } + var tr testResponse + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return nil, err + } + if tr.ID == "" || tr.Email == "" { + return nil, fmt.Errorf("happyDeliver returned empty test allocation") + } + return &tr, nil +} + +var errTimeout = fmt.Errorf("timeout waiting for analysis") + +func waitForAnalysis(ctx context.Context, client *http.Client, cfg *runConfig, id string) error { + deadline := time.Now().Add(cfg.WaitTimeout) + for { + if time.Now().After(deadline) { + return errTimeout + } + status, err := getTestStatus(ctx, client, cfg, id) + if err != nil { + return err + } + if status == "analyzed" { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(cfg.PollInterval): + } + } +} + +func getTestStatus(ctx context.Context, client *http.Client, cfg *runConfig, id string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(cfg.HappyDeliverURL, "/api/test/"+id), nil) + if err != nil { + return "", err + } + addAuth(req, cfg) + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + io.Copy(io.Discard, resp.Body) + return "", fmt.Errorf("GET /api/test returned %s", resp.Status) + } + var t struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&t); err != nil { + return "", err + } + return t.Status, nil +} + +func fetchReport(ctx context.Context, client *http.Client, cfg *runConfig, id string) (json.RawMessage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(cfg.HappyDeliverURL, "/api/report/"+id), nil) + if err != nil { + return nil, err + } + addAuth(req, cfg) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("GET /api/report returned %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, nil +} + +func addAuth(req *http.Request, cfg *runConfig) { + if cfg.HappyDeliverToken != "" { + req.Header.Set("Authorization", "Bearer "+cfg.HappyDeliverToken) + } + req.Header.Set("Accept", "application/json") +} + +func joinURL(base, path string) string { + return strings.TrimRight(base, "/") + path +} + +func sendTestEmail(ctx context.Context, cfg *runConfig, recipient string) error { + addr := net.JoinHostPort(cfg.SMTPHost, strconv.Itoa(cfg.SMTPPort)) + msg := buildMessage(cfg, recipient) + + dialer := &net.Dialer{Timeout: 30 * time.Second} + deadline, ok := ctx.Deadline() + if !ok { + deadline = time.Now().Add(2 * time.Minute) + } + + var conn net.Conn + var err error + if cfg.SMTPTLS == "tls" { + conn, err = tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ServerName: cfg.SMTPHost}) + } else { + conn, err = dialer.DialContext(ctx, "tcp", addr) + } + if err != nil { + return fmt.Errorf("dial %s: %w", addr, err) + } + _ = conn.SetDeadline(deadline) + + c, err := smtp.NewClient(conn, cfg.SMTPHost) + if err != nil { + conn.Close() + return err + } + defer c.Close() + + if cfg.SMTPTLS == "starttls" { + if ok, _ := c.Extension("STARTTLS"); !ok { + return fmt.Errorf("server does not advertise STARTTLS") + } + if err := c.StartTLS(&tls.Config{ServerName: cfg.SMTPHost}); err != nil { + return fmt.Errorf("starttls: %w", err) + } + } + + if cfg.SMTPUsername != "" { + auth := smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, cfg.SMTPHost) + if err := c.Auth(auth); err != nil { + return fmt.Errorf("auth: %w", err) + } + } + + if err := c.Mail(cfg.FromAddress); err != nil { + return fmt.Errorf("MAIL FROM: %w", err) + } + if err := c.Rcpt(recipient); err != nil { + return fmt.Errorf("RCPT TO: %w", err) + } + wc, err := c.Data() + if err != nil { + return fmt.Errorf("DATA: %w", err) + } + if _, err := wc.Write(msg); err != nil { + wc.Close() + return fmt.Errorf("write body: %w", err) + } + if err := wc.Close(); err != nil { + return fmt.Errorf("close DATA: %w", err) + } + return c.Quit() +} + +func buildMessage(cfg *runConfig, recipient string) []byte { + boundary := "happydeliver-" + strconv.FormatInt(time.Now().UnixNano(), 36) + var buf bytes.Buffer + fmt.Fprintf(&buf, "From: %s\r\n", cfg.FromHeader) + fmt.Fprintf(&buf, "To: %s\r\n", recipient) + fmt.Fprintf(&buf, "Subject: %s\r\n", mime.QEncoding.Encode("UTF-8", cfg.Subject)) + fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)) + fmt.Fprintf(&buf, "Message-ID: <%d.happydeliver@%s>\r\n", time.Now().UnixNano(), hostFromAddress(cfg.FromAddress)) + fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n") + fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=%q\r\n\r\n", boundary) + + fmt.Fprintf(&buf, "--%s\r\n", boundary) + fmt.Fprintf(&buf, "Content-Type: text/plain; charset=UTF-8\r\n") + fmt.Fprintf(&buf, "Content-Transfer-Encoding: 8bit\r\n\r\n") + buf.WriteString(cfg.BodyText) + if !strings.HasSuffix(cfg.BodyText, "\r\n") { + buf.WriteString("\r\n") + } + + fmt.Fprintf(&buf, "--%s\r\n", boundary) + fmt.Fprintf(&buf, "Content-Type: text/html; charset=UTF-8\r\n") + fmt.Fprintf(&buf, "Content-Transfer-Encoding: 8bit\r\n\r\n") + buf.WriteString(cfg.BodyHTML) + buf.WriteString("\r\n") + fmt.Fprintf(&buf, "--%s--\r\n", boundary) + return buf.Bytes() +} + +func hostFromAddress(addr string) string { + if i := strings.LastIndex(addr, "@"); i >= 0 { + return addr[i+1:] + } + return "localhost" +} diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..68bacef --- /dev/null +++ b/checker/collect_test.go @@ -0,0 +1,691 @@ +package checker + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net" + "net/http" + "net/http/httptest" + "net/mail" + "net/url" + "strconv" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func baseValidOptions() sdk.CheckerOptions { + return sdk.CheckerOptions{ + "happydeliver_url": "https://deliver.example.org", + "happydeliver_token": "tok", + "smtp_host": "smtp.example.org", + "smtp_port": float64(587), + "smtp_tls": "starttls", + "from_address": "test@example.org", + } +} + +func TestLoadConfigDefaults(t *testing.T) { + cfg, err := loadConfig(baseValidOptions()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.SMTPPort != 587 { + t.Errorf("SMTPPort = %d, want 587", cfg.SMTPPort) + } + if cfg.SMTPTLS != "starttls" { + t.Errorf("SMTPTLS = %q, want starttls", cfg.SMTPTLS) + } + if cfg.Subject != defaultSubject { + t.Errorf("Subject = %q, want default", cfg.Subject) + } + if cfg.BodyText != defaultBodyText { + t.Errorf("BodyText not defaulted") + } + if cfg.BodyHTML != defaultBodyHTML { + t.Errorf("BodyHTML not defaulted") + } + if cfg.WaitTimeout != 900*time.Second { + t.Errorf("WaitTimeout = %v, want 900s", cfg.WaitTimeout) + } + if cfg.PollInterval != 5*time.Second { + t.Errorf("PollInterval = %v, want 5s", cfg.PollInterval) + } + if cfg.FromAddress != "test@example.org" { + t.Errorf("FromAddress = %q", cfg.FromAddress) + } + if cfg.FromHeader != "" { + t.Errorf("FromHeader = %q", cfg.FromHeader) + } +} + +func TestLoadConfigPollClamping(t *testing.T) { + for _, tc := range []struct { + in, want int + }{ + {0, 2}, {1, 2}, {2, 2}, {30, 30}, {60, 60}, {120, 60}, + } { + opts := baseValidOptions() + opts["poll_interval"] = float64(tc.in) + cfg, err := loadConfig(opts) + if err != nil { + t.Fatalf("in=%d: %v", tc.in, err) + } + if cfg.PollInterval != time.Duration(tc.want)*time.Second { + t.Errorf("in=%d: got %v, want %ds", tc.in, cfg.PollInterval, tc.want) + } + } +} + +func TestLoadConfigValidationErrors(t *testing.T) { + cases := []struct { + name string + mutate func(sdk.CheckerOptions) + want string + }{ + {"missing url", func(o sdk.CheckerOptions) { delete(o, "happydeliver_url") }, "happydeliver_url is required"}, + {"bad url", func(o sdk.CheckerOptions) { o["happydeliver_url"] = "not a url" }, "invalid happydeliver_url"}, + {"non-http scheme", func(o sdk.CheckerOptions) { o["happydeliver_url"] = "ftp://x.test" }, "must use http or https"}, + {"missing host", func(o sdk.CheckerOptions) { o["happydeliver_url"] = "https://" }, "missing a host"}, + {"missing smtp_host", func(o sdk.CheckerOptions) { delete(o, "smtp_host") }, "smtp_host is required"}, + {"missing from", func(o sdk.CheckerOptions) { delete(o, "from_address") }, "from_address is required"}, + {"bad from", func(o sdk.CheckerOptions) { o["from_address"] = "not-an-address" }, "invalid from_address"}, + {"bad tls mode", func(o sdk.CheckerOptions) { o["smtp_tls"] = "weird" }, "smtp_tls must be one of"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := baseValidOptions() + tc.mutate(opts) + _, err := loadConfig(opts) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want substring %q", err, tc.want) + } + }) + } +} + +func TestLoadConfigAcceptsDisplayNameFrom(t *testing.T) { + opts := baseValidOptions() + opts["from_address"] = "Alice " + cfg, err := loadConfig(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.FromAddress != "alice@example.org" { + t.Errorf("FromAddress should be the bare address, got %q", cfg.FromAddress) + } + if !strings.Contains(cfg.FromHeader, "") { + t.Errorf("FromHeader should keep display form, got %q", cfg.FromHeader) + } +} + +func TestLoadConfigTrimsURL(t *testing.T) { + opts := baseValidOptions() + opts["happydeliver_url"] = " https://deliver.example.org " + cfg, err := loadConfig(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.HappyDeliverURL != "https://deliver.example.org" { + t.Errorf("URL not trimmed: %q", cfg.HappyDeliverURL) + } +} + +func TestJoinURL(t *testing.T) { + cases := map[string]string{ + "https://x": "https://x/api", + "https://x/": "https://x/api", + "https://x///": "https://x/api", + "https://x/foo": "https://x/foo/api", + } + for base, want := range cases { + if got := joinURL(base, "/api"); got != want { + t.Errorf("joinURL(%q) = %q, want %q", base, got, want) + } + } +} + +func TestHostFromAddress(t *testing.T) { + cases := map[string]string{ + "user@example.org": "example.org", + "a@b@example.org": "example.org", + "no-at-sign": "localhost", + "": "localhost", + } + for in, want := range cases { + if got := hostFromAddress(in); got != want { + t.Errorf("hostFromAddress(%q) = %q, want %q", in, got, want) + } + } +} + +func TestStringOpt(t *testing.T) { + opts := sdk.CheckerOptions{"k": "v", "n": float64(1)} + if stringOpt(opts, "k") != "v" { + t.Error("expected v") + } + if stringOpt(opts, "missing") != "" { + t.Error("missing key should give empty string") + } + if stringOpt(opts, "n") != "" { + t.Error("non-string value should give empty string") + } +} + +func TestBuildMessageStructure(t *testing.T) { + cfg := &runConfig{ + FromAddress: "alice@example.org", + FromHeader: "Alice ", + Subject: "Hello: accents", + BodyText: "plain body", + BodyHTML: "

html body

", + } + raw := buildMessage(cfg, "rcpt@deliver.test") + + msg, err := mail.ReadMessage(strings.NewReader(string(raw))) + if err != nil { + t.Fatalf("not a parseable RFC 5322 message: %v\n--\n%s", err, raw) + } + if got := msg.Header.Get("From"); !strings.Contains(got, "alice@example.org") { + t.Errorf("From header = %q", got) + } + if got := msg.Header.Get("To"); got != "rcpt@deliver.test" { + t.Errorf("To header = %q", got) + } + + // Subject: Q-encoded UTF-8. + dec := new(mime.WordDecoder) + subj, err := dec.DecodeHeader(msg.Header.Get("Subject")) + if err != nil { + t.Fatalf("subject decode: %v", err) + } + if subj != "Hello: accents" { + t.Errorf("decoded subject = %q", subj) + } + + if msg.Header.Get("MIME-Version") != "1.0" { + t.Errorf("missing MIME-Version") + } + if mid := msg.Header.Get("Message-ID"); !strings.Contains(mid, "@example.org>") { + t.Errorf("Message-ID = %q", mid) + } + + mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("content-type: %v", err) + } + if mediaType != "multipart/alternative" { + t.Errorf("media type = %q", mediaType) + } + if params["boundary"] == "" { + t.Fatal("missing boundary") + } + + mr := multipart.NewReader(msg.Body, params["boundary"]) + var seenText, seenHTML bool + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("next part: %v", err) + } + if cte := p.Header.Get("Content-Transfer-Encoding"); cte != "8bit" { + t.Errorf("part CTE = %q, want 8bit", cte) + } + body, _ := io.ReadAll(p) + ct := p.Header.Get("Content-Type") + switch { + case strings.HasPrefix(ct, "text/plain"): + seenText = true + if !strings.Contains(string(body), "plain body") { + t.Errorf("plain part body = %q", body) + } + case strings.HasPrefix(ct, "text/html"): + seenHTML = true + if !strings.Contains(string(body), "html body") { + t.Errorf("html part body = %q", body) + } + default: + t.Errorf("unexpected part Content-Type %q", ct) + } + } + if !seenText || !seenHTML { + t.Errorf("missing parts: text=%v html=%v", seenText, seenHTML) + } +} + +func TestBuildMessageBodyTextNormalisation(t *testing.T) { + cfg := &runConfig{ + FromAddress: "a@b.test", FromHeader: "", + Subject: "s", BodyText: "no newline", BodyHTML: "

x

", + } + raw := string(buildMessage(cfg, "r@x.test")) + // The plain body must be CRLF-terminated before the next boundary line. + if !strings.Contains(raw, "no newline\r\n--") { + t.Errorf("plain body was not CRLF-normalised before boundary:\n%s", raw) + } +} + +// --- HTTP client tests --------------------------------------------------- + +func TestAllocateTestSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/test" { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer hunter2" { + t.Errorf("auth header = %q", got) + } + _, _ = io.WriteString(w, `{"id":"abc","email":"abc@deliver.test"}`) + })) + defer srv.Close() + + cfg := &runConfig{HappyDeliverURL: srv.URL, HappyDeliverToken: "hunter2"} + tr, err := allocateTest(context.Background(), srv.Client(), cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tr.ID != "abc" || tr.Email != "abc@deliver.test" { + t.Errorf("got %+v", tr) + } +} + +func TestAllocateTestNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusServiceUnavailable) + })) + defer srv.Close() + _, err := allocateTest(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL}) + if err == nil || !strings.Contains(err.Error(), "503") { + t.Fatalf("expected 503 error, got %v", err) + } +} + +func TestAllocateTestEmptyAllocation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"id":"","email":""}`) + })) + defer srv.Close() + _, err := allocateTest(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL}) + if err == nil || !strings.Contains(err.Error(), "empty test allocation") { + t.Fatalf("got %v", err) + } +} + +func TestAllocateTestNoToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization should be empty, got %q", got) + } + _, _ = io.WriteString(w, `{"id":"x","email":"x@y"}`) + })) + defer srv.Close() + if _, err := allocateTest(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL}); err != nil { + t.Fatal(err) + } +} + +func TestGetTestStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/test/abc" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, `{"status":"analyzed"}`) + })) + defer srv.Close() + st, err := getTestStatus(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL}, "abc") + if err != nil { + t.Fatal(err) + } + if st != "analyzed" { + t.Errorf("status = %q", st) + } +} + +func TestFetchReport(t *testing.T) { + body := `{"score":42}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/report/xyz" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, body) + })) + defer srv.Close() + raw, err := fetchReport(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL}, "xyz") + if err != nil { + t.Fatal(err) + } + if string(raw) != body { + t.Errorf("body = %q", raw) + } +} + +func TestWaitForAnalysisPolls(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + if hits < 3 { + _, _ = io.WriteString(w, `{"status":"pending"}`) + return + } + _, _ = io.WriteString(w, `{"status":"analyzed"}`) + })) + defer srv.Close() + cfg := &runConfig{HappyDeliverURL: srv.URL, WaitTimeout: 5 * time.Second, PollInterval: 10 * time.Millisecond} + if err := waitForAnalysis(context.Background(), srv.Client(), cfg, "x"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hits < 3 { + t.Errorf("expected at least 3 hits, got %d", hits) + } +} + +func TestWaitForAnalysisTimeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"status":"pending"}`) + })) + defer srv.Close() + cfg := &runConfig{HappyDeliverURL: srv.URL, WaitTimeout: 25 * time.Millisecond, PollInterval: 10 * time.Millisecond} + err := waitForAnalysis(context.Background(), srv.Client(), cfg, "x") + if !errors.Is(err, errTimeout) { + t.Fatalf("expected errTimeout, got %v", err) + } +} + +func TestWaitForAnalysisContextCancelled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"status":"pending"}`) + })) + defer srv.Close() + ctx, cancel := context.WithCancel(context.Background()) + cfg := &runConfig{HappyDeliverURL: srv.URL, WaitTimeout: time.Hour, PollInterval: 10 * time.Millisecond} + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + err := waitForAnalysis(ctx, srv.Client(), cfg, "x") + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +// --- End-to-end Collect with httptest + in-process SMTP ------------------ + +func TestCollectHappyPath(t *testing.T) { + smtpAddr, smtpReq := startFakeSMTP(t) + + const reportJSON = `{ + "score": 88, "grade": "B", + "summary": { + "dns_score": 90, "dns_grade": "A", + "authentication_score": 85, "authentication_grade": "B", + "spam_score": 80, "spam_grade": "B", + "blacklist_score": 100, "blacklist_grade": "A", + "header_score": 75, "header_grade": "C", + "content_score": 70, "content_grade": "C" + } + }` + + mux := http.NewServeMux() + mux.HandleFunc("/api/test", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"id":"id1","email":"id1@deliver.test"}`) + }) + mux.HandleFunc("/api/test/id1", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"status":"analyzed"}`) + }) + mux.HandleFunc("/api/report/id1", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, reportJSON) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + host, port, _ := net.SplitHostPort(smtpAddr) + p, _ := strconv.Atoi(port) + + provider := &happyDeliverProvider{} + out, err := provider.Collect(context.Background(), sdk.CheckerOptions{ + "happydeliver_url": srv.URL, + "smtp_host": host, + "smtp_port": float64(p), + "smtp_tls": "none", + "from_address": "alice@example.org", + "poll_interval": float64(2), + }) + if err != nil { + t.Fatalf("Collect returned err: %v", err) + } + data, ok := out.(*HappyDeliverData) + if !ok { + t.Fatalf("Collect returned %T, want *HappyDeliverData", out) + } + if data.Phase != "ok" { + t.Errorf("Phase = %q (Error=%q), want ok", data.Phase, data.Error) + } + if data.Scores[SectionOverall] != 88 { + t.Errorf("overall score = %d", data.Scores[SectionOverall]) + } + if data.RecipientEmail != "id1@deliver.test" { + t.Errorf("RecipientEmail = %q", data.RecipientEmail) + } + if data.LatencySeconds < 0 { + t.Errorf("LatencySeconds should be non-negative, got %v", data.LatencySeconds) + } + + // Verify the SMTP server received a sane envelope. + select { + case got := <-smtpReq: + if got.from != "alice@example.org" { + t.Errorf("MAIL FROM = %q", got.from) + } + if got.to != "id1@deliver.test" { + t.Errorf("RCPT TO = %q", got.to) + } + if !strings.Contains(got.data, "Subject:") { + t.Errorf("data missing headers: %s", got.data) + } + case <-time.After(2 * time.Second): + t.Fatal("SMTP server never received a message") + } +} + +func TestCollectAllocateFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + provider := &happyDeliverProvider{} + out, err := provider.Collect(context.Background(), sdk.CheckerOptions{ + "happydeliver_url": srv.URL, + "smtp_host": "irrelevant", + "smtp_tls": "none", + "from_address": "a@b.test", + }) + if err != nil { + t.Fatalf("Collect should swallow error, got %v", err) + } + d := out.(*HappyDeliverData) + if d.Phase != "allocate" { + t.Errorf("phase = %q, want allocate", d.Phase) + } + if d.Error == "" { + t.Error("expected an error message") + } +} + +func TestCollectParseFailure(t *testing.T) { + smtpAddr, _ := startFakeSMTP(t) + + mux := http.NewServeMux() + mux.HandleFunc("/api/test", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"id":"i","email":"i@deliver.test"}`) + }) + mux.HandleFunc("/api/test/i", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"status":"analyzed"}`) + }) + mux.HandleFunc("/api/report/i", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `not json`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + host, port, _ := net.SplitHostPort(smtpAddr) + p, _ := strconv.Atoi(port) + + provider := &happyDeliverProvider{} + out, err := provider.Collect(context.Background(), sdk.CheckerOptions{ + "happydeliver_url": srv.URL, + "smtp_host": host, + "smtp_port": float64(p), + "smtp_tls": "none", + "from_address": "alice@example.org", + "poll_interval": float64(2), + }) + if err != nil { + t.Fatal(err) + } + d := out.(*HappyDeliverData) + if d.Phase != "parse" { + t.Errorf("phase = %q, want parse (Error=%q)", d.Phase, d.Error) + } + if !strings.HasPrefix(d.Error, "parse:") { + t.Errorf("error = %q", d.Error) + } +} + +// --- minimal in-process SMTP server ------------------------------------- + +type smtpReceived struct{ from, to, data string } + +func startFakeSMTP(t *testing.T) (string, <-chan smtpReceived) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + out := make(chan smtpReceived, 4) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleSMTPConn(conn, out) + } + }() + return ln.Addr().String(), out +} + +func handleSMTPConn(conn net.Conn, out chan<- smtpReceived) { + defer conn.Close() + br := bufio.NewReader(conn) + write := func(s string) { _, _ = io.WriteString(conn, s) } + + write("220 fake.test ESMTP\r\n") + var rec smtpReceived + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + switch { + case strings.HasPrefix(strings.ToUpper(line), "EHLO"), strings.HasPrefix(strings.ToUpper(line), "HELO"): + write("250-fake.test\r\n250 OK\r\n") + case strings.HasPrefix(strings.ToUpper(line), "MAIL FROM:"): + rec.from = extractAngleAddr(line) + write("250 OK\r\n") + case strings.HasPrefix(strings.ToUpper(line), "RCPT TO:"): + rec.to = extractAngleAddr(line) + write("250 OK\r\n") + case strings.ToUpper(line) == "DATA": + write("354 send data\r\n") + var b strings.Builder + for { + dl, err := br.ReadString('\n') + if err != nil { + return + } + if dl == ".\r\n" || strings.TrimRight(dl, "\r\n") == "." { + break + } + b.WriteString(dl) + } + rec.data = b.String() + write("250 OK\r\n") + out <- rec + rec = smtpReceived{} + case strings.ToUpper(line) == "QUIT": + write("221 bye\r\n") + return + case strings.ToUpper(line) == "RSET": + write("250 OK\r\n") + case strings.ToUpper(line) == "NOOP": + write("250 OK\r\n") + default: + write("250 OK\r\n") + } + } +} + +func extractAngleAddr(line string) string { + i := strings.Index(line, "<") + j := strings.Index(line, ">") + if i >= 0 && j > i { + return line[i+1 : j] + } + if k := strings.Index(line, ":"); k > 0 { + return strings.TrimSpace(line[k+1:]) + } + return "" +} + +// --- ensure URLs we build actually parse --------------------------------- + +func TestJoinURLProducesParseableURL(t *testing.T) { + u, err := url.Parse(joinURL("https://x.test/", "/api/foo")) + if err != nil { + t.Fatal(err) + } + if u.Path != "/api/foo" { + t.Errorf("path = %q", u.Path) + } +} + +// sanity: errTimeout text must remain stable for log scrapers. +func TestErrTimeoutMessage(t *testing.T) { + if errTimeout.Error() != "timeout waiting for analysis" { + t.Errorf("errTimeout text changed: %q", errTimeout.Error()) + } + if !errors.Is(fmt.Errorf("wrap: %w", errTimeout), errTimeout) { + t.Error("errTimeout not unwrappable") + } + // Make sure JSON marshalling of HappyDeliverData round-trips. + d := HappyDeliverData{Phase: "ok", Scores: map[string]int{SectionOverall: 1}} + raw, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + var back HappyDeliverData + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + if back.Scores[SectionOverall] != 1 { + t.Errorf("round-trip lost scores: %+v", back) + } +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..ecdef33 --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,157 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "built-in" + +func Definition() *sdk.CheckerDefinition { + def := &sdk.CheckerDefinition{ + ID: "happydeliver", + Name: "Outbound deliverability (via happyDeliver)", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToDomain: true, + ApplyToService: true, + LimitToServices: []string{"svcs.MXs"}, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKeyHappyDeliver}, + Options: sdk.CheckerOptionsDocumentation{ + AdminOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "happydeliver_url", + Type: "string", + Label: "happyDeliver instance URL", + Description: "Default base URL of the happyDeliver API. Users may override per-domain.", + Placeholder: "https://deliver.example.org", + }, + { + Id: "happydeliver_token", + Type: "string", + Label: "happyDeliver API token", + Description: "Default bearer token for the happyDeliver API.", + Secret: true, + }, + }, + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "happydeliver_url", + Type: "string", + Label: "happyDeliver instance URL (override)", + Description: "Override the operator-provided happyDeliver URL.", + }, + { + Id: "happydeliver_token", + Type: "string", + Label: "happyDeliver API token (override)", + Description: "Override the operator-provided happyDeliver token.", + Secret: true, + }, + { + Id: "smtp_host", + Type: "string", + Label: "Sending SMTP host", + Description: "Hostname or IP of the submission server used to send the test email.", + Required: true, + }, + { + Id: "smtp_port", + Type: "number", + Label: "Sending SMTP port", + Description: "Submission port (typically 587 for STARTTLS, 465 for implicit TLS, 25 for plain).", + Default: float64(587), + Required: true, + }, + { + Id: "smtp_username", + Type: "string", + Label: "SMTP username", + Description: "Username used to authenticate against the submission server.", + }, + { + Id: "smtp_password", + Type: "string", + Label: "SMTP password", + Description: "Password used to authenticate against the submission server.", + Secret: true, + }, + { + Id: "smtp_tls", + Type: "string", + Label: "TLS mode", + Description: "How to negotiate TLS with the submission server.", + Choices: []string{"starttls", "tls", "none"}, + Default: "starttls", + }, + { + Id: "from_address", + Type: "string", + Label: "From address", + Description: "Address used in the From header of the test email. Must be in the tested domain.", + Required: true, + }, + { + Id: "subject_override", + Type: "string", + Label: "Subject (optional)", + Description: "Override the default test subject.", + }, + { + Id: "body_text_override", + Type: "string", + Label: "Plain-text body (optional)", + Textarea: true, + Description: "Override the default plain-text body.", + }, + { + Id: "body_html_override", + Type: "string", + Label: "HTML body (optional)", + Textarea: true, + Description: "Override the default HTML body.", + }, + { + Id: "wait_timeout", + Type: "number", + Label: "Wait timeout (s)", + Description: "Seconds to wait for happyDeliver to receive and analyse the message.", + Default: float64(900), + }, + { + Id: "poll_interval", + Type: "number", + Label: "Poll interval (s)", + Description: "Seconds between status polls. Clamped to [2, 60].", + Default: float64(5), + }, + }, + DomainOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Domain name", + AutoFill: sdk.AutoFillDomainName, + }, + }, + }, + Rules: []sdk.CheckRule{ + NewSectionRule(SectionOverall, "Overall", 70), + NewSectionRule(SectionDNS, "DNS", 70), + NewSectionRule(SectionAuthentication, "Authentication (SPF/DKIM/DMARC)", 80), + NewSectionRule(SectionSpam, "Spam filters", 70), + NewSectionRule(SectionBlacklist, "Blacklists", 90), + NewSectionRule(SectionHeader, "Headers", 70), + NewSectionRule(SectionContent, "Content", 60), + NewLifecycleRule(), + }, + Interval: &sdk.CheckIntervalSpec{ + Min: 1 * time.Hour, + Max: 30 * 24 * time.Hour, + Default: 7 * 24 * time.Hour, + }, + HasMetrics: true, + } + return def +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..dff2925 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,174 @@ +//go:build standalone + +package checker + +import ( + "errors" + "net/http" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func (p *happyDeliverProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain_name", + Type: "string", + Label: "Domain name", + Placeholder: "example.com", + Required: true, + Description: "Domain whose outbound deliverability will be tested.", + }, + { + Id: "happydeliver_url", + Type: "string", + Label: "happyDeliver instance URL", + Placeholder: "https://deliver.example.org", + Required: true, + Description: "Base URL of the happyDeliver API used to allocate the test address and fetch the report.", + }, + { + Id: "happydeliver_token", + Type: "string", + Label: "happyDeliver API token", + Description: "Bearer token for the happyDeliver API (optional if the instance is open).", + }, + { + Id: "smtp_host", + Type: "string", + Label: "Sending SMTP host", + Required: true, + Description: "Hostname or IP of the submission server used to send the test email.", + }, + { + Id: "smtp_port", + Type: "string", + Label: "Sending SMTP port", + Placeholder: "587", + Description: "Submission port (587 for STARTTLS, 465 for implicit TLS, 25 for plain). Defaults to 587.", + }, + { + Id: "smtp_tls", + Type: "string", + Label: "TLS mode", + Placeholder: "starttls", + Description: "How to negotiate TLS with the submission server: starttls, tls, or none. Defaults to starttls.", + }, + { + Id: "smtp_username", + Type: "string", + Label: "SMTP username", + Description: "Username used to authenticate against the submission server (optional).", + }, + { + Id: "smtp_password", + Type: "string", + Label: "SMTP password", + Description: "Password used to authenticate against the submission server (optional).", + }, + { + Id: "from_address", + Type: "string", + Label: "From address", + Placeholder: "postmaster@example.com", + Required: true, + Description: "Address used in the From header of the test email. Must belong to the tested domain.", + }, + { + Id: "subject_override", + Type: "string", + Label: "Subject (optional)", + Description: "Override the default test subject.", + }, + { + Id: "body_text_override", + Type: "string", + Label: "Plain-text body (optional)", + Description: "Override the default plain-text body.", + }, + { + Id: "body_html_override", + Type: "string", + Label: "HTML body (optional)", + Description: "Override the default HTML body.", + }, + { + Id: "wait_timeout", + Type: "string", + Label: "Wait timeout (s)", + Placeholder: "900", + Description: "Seconds to wait for happyDeliver to receive and analyse the message. Defaults to 900.", + }, + { + Id: "poll_interval", + Type: "string", + Label: "Poll interval (s)", + Placeholder: "5", + Description: "Seconds between status polls. Clamped to [2, 60]. Defaults to 5.", + }, + } +} + +func (p *happyDeliverProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSuffix(strings.TrimSpace(r.FormValue("domain_name")), ".") + if domain == "" { + return nil, errors.New("domain_name is required") + } + + url := strings.TrimSpace(r.FormValue("happydeliver_url")) + if url == "" { + return nil, errors.New("happydeliver_url is required") + } + + smtpHost := strings.TrimSpace(r.FormValue("smtp_host")) + if smtpHost == "" { + return nil, errors.New("smtp_host is required") + } + + from := strings.TrimSpace(r.FormValue("from_address")) + if from == "" { + return nil, errors.New("from_address is required") + } + + opts := sdk.CheckerOptions{ + "domain_name": domain, + "happydeliver_url": url, + "happydeliver_token": strings.TrimSpace(r.FormValue("happydeliver_token")), + "smtp_host": smtpHost, + "smtp_username": strings.TrimSpace(r.FormValue("smtp_username")), + "smtp_password": r.FormValue("smtp_password"), + "from_address": from, + "subject_override": r.FormValue("subject_override"), + "body_text_override": r.FormValue("body_text_override"), + "body_html_override": r.FormValue("body_html_override"), + } + + if v := strings.TrimSpace(r.FormValue("smtp_tls")); v != "" { + opts["smtp_tls"] = strings.ToLower(v) + } + if v := strings.TrimSpace(r.FormValue("smtp_port")); v != "" { + port, err := strconv.Atoi(v) + if err != nil { + return nil, errors.New("smtp_port must be a number") + } + opts["smtp_port"] = float64(port) + } + if v := strings.TrimSpace(r.FormValue("wait_timeout")); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return nil, errors.New("wait_timeout must be a number") + } + opts["wait_timeout"] = float64(n) + } + if v := strings.TrimSpace(r.FormValue("poll_interval")); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return nil, errors.New("poll_interval must be a number") + } + opts["poll_interval"] = float64(n) + } + + return opts, nil +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..2cf6a50 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,47 @@ +package checker + +import ( + "encoding/json" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Provider() sdk.ObservationProvider { + return &happyDeliverProvider{} +} + +type happyDeliverProvider struct{} + +func (p *happyDeliverProvider) Key() sdk.ObservationKey { + return ObservationKeyHappyDeliver +} + +func (p *happyDeliverProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} + +func (p *happyDeliverProvider) ExtractMetrics(ctx sdk.ReportContext, collectedAt time.Time) ([]sdk.CheckMetric, error) { + var data HappyDeliverData + if err := json.Unmarshal(ctx.Data(), &data); err != nil { + return nil, err + } + if len(data.Scores) == 0 { + return nil, nil + } + metrics := make([]sdk.CheckMetric, 0, len(AllSections)) + for _, section := range AllSections { + score, ok := data.Scores[section] + if !ok { + continue + } + metrics = append(metrics, sdk.CheckMetric{ + Name: "happydeliver_score", + Value: float64(score), + Unit: "points", + Labels: map[string]string{"section": section}, + Timestamp: collectedAt, + }) + } + return metrics, nil +} diff --git a/checker/provider_test.go b/checker/provider_test.go new file mode 100644 index 0000000..f2d9629 --- /dev/null +++ b/checker/provider_test.go @@ -0,0 +1,138 @@ +package checker + +import ( + "encoding/json" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type fakeReportCtx struct { + data json.RawMessage +} + +func (f fakeReportCtx) Data() json.RawMessage { return f.data } +func (fakeReportCtx) Related(sdk.ObservationKey) []sdk.RelatedObservation { return nil } +func (fakeReportCtx) States() []sdk.CheckState { return nil } + +func TestProviderKey(t *testing.T) { + p := Provider() + if p.Key() != ObservationKeyHappyDeliver { + t.Errorf("Key = %q", p.Key()) + } +} + +func TestProviderImplementsMetricsReporter(t *testing.T) { + if _, ok := Provider().(sdk.CheckerMetricsReporter); !ok { + t.Fatal("provider should implement CheckerMetricsReporter") + } +} + +func TestExtractMetrics(t *testing.T) { + d := HappyDeliverData{ + Phase: "ok", + Scores: map[string]int{ + SectionOverall: 88, SectionDNS: 90, SectionAuthentication: 70, + }, + } + raw, _ := json.Marshal(d) + now := time.Now() + metrics, err := Provider().(sdk.CheckerMetricsReporter).ExtractMetrics(fakeReportCtx{data: raw}, now) + if err != nil { + t.Fatal(err) + } + if len(metrics) != 3 { + t.Fatalf("expected 3 metrics, got %d", len(metrics)) + } + // Order must match AllSections (overall first). + if metrics[0].Labels["section"] != SectionOverall || metrics[0].Value != 88 { + t.Errorf("metrics[0] = %+v", metrics[0]) + } + for _, m := range metrics { + if m.Name != "happydeliver_score" { + t.Errorf("name = %q", m.Name) + } + if m.Unit != "points" { + t.Errorf("unit = %q", m.Unit) + } + if !m.Timestamp.Equal(now) { + t.Errorf("ts = %v", m.Timestamp) + } + } +} + +func TestExtractMetricsNoScores(t *testing.T) { + d := HappyDeliverData{Phase: "send", Error: "x"} + raw, _ := json.Marshal(d) + metrics, err := Provider().(sdk.CheckerMetricsReporter).ExtractMetrics(fakeReportCtx{data: raw}, time.Now()) + if err != nil { + t.Fatal(err) + } + if metrics != nil { + t.Errorf("expected nil metrics, got %v", metrics) + } +} + +func TestExtractMetricsBadPayload(t *testing.T) { + _, err := Provider().(sdk.CheckerMetricsReporter).ExtractMetrics(fakeReportCtx{data: []byte("garbage")}, time.Now()) + if err == nil { + t.Fatal("expected error on bad JSON") + } +} + +func TestDefinitionShape(t *testing.T) { + def := Definition() + if def.ID != "happydeliver" { + t.Errorf("ID = %q", def.ID) + } + if !def.Availability.ApplyToDomain { + t.Error("should apply to domain") + } + if !def.HasMetrics { + t.Error("HasMetrics should be true") + } + // Section rules + lifecycle rule. + if got, want := len(def.Rules), len(AllSections)+1; got != want { + t.Errorf("rule count = %d, want %d", got, want) + } + // Each section must have a corresponding rule named happydeliver.score.
. + have := map[string]bool{} + for _, r := range def.Rules { + have[r.Name()] = true + } + for _, s := range AllSections { + if !have["happydeliver.score."+s] { + t.Errorf("missing rule for section %q", s) + } + } + if !have["happydeliver.lifecycle"] { + t.Error("missing lifecycle rule") + } + if def.Interval == nil || def.Interval.Default != 7*24*time.Hour { + t.Errorf("interval = %+v", def.Interval) + } +} + +func TestDefinitionBuildRulesInfo(t *testing.T) { + def := Definition() + def.BuildRulesInfo() + if len(def.RulesInfo) != len(def.Rules) { + t.Fatalf("RulesInfo len = %d, want %d", len(def.RulesInfo), len(def.Rules)) + } + // Section rules expose Options(); the lifecycle rule does not. + var withOpts, withoutOpts int + for _, info := range def.RulesInfo { + if info.Options != nil { + withOpts++ + } else { + withoutOpts++ + } + } + if withOpts != len(AllSections) { + t.Errorf("rules-with-options = %d, want %d", withOpts, len(AllSections)) + } + if withoutOpts != 1 { + t.Errorf("rules-without-options = %d, want 1 (lifecycle)", withoutOpts) + } +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..6fb97aa --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,145 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func NewSectionRule(section, label string, defaultMin float64) sdk.CheckRule { + return §ionRule{section: section, label: label, defaultMin: defaultMin} +} + +type sectionRule struct { + section string + label string + defaultMin float64 +} + +func (r *sectionRule) Name() string { return "happydeliver.score." + r.section } + +func (r *sectionRule) Description() string { + return fmt.Sprintf("Verify happyDeliver's %s score is above the configured minimum.", r.label) +} + +func (r *sectionRule) optionID() string { return "min_score_" + r.section } + +func (r *sectionRule) Options() sdk.CheckerOptionsDocumentation { + return sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: r.optionID(), + Type: "number", + Label: fmt.Sprintf("Minimum %s score", r.label), + Description: fmt.Sprintf("Minimum acceptable score (0-100) for the %s section.", r.label), + Default: r.defaultMin, + }, + }, + } +} + +func (r *sectionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + var data HappyDeliverData + if err := obs.Get(ctx, ObservationKeyHappyDeliver, &data); err != nil { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("Failed to read happyDeliver observation: %v", err), + Code: "happydeliver.observation.error", + }} + } + if data.Phase != "ok" || data.Scores == nil { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Message: "No happyDeliver report available yet", + Code: "happydeliver.score.unavailable", + Subject: r.section, + }} + } + score, ok := data.Scores[r.section] + if !ok { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Message: fmt.Sprintf("happyDeliver report has no %s score", r.label), + Code: "happydeliver.score.missing", + Subject: r.section, + }} + } + threshold := sdk.GetFloatOption(opts, r.optionID(), r.defaultMin) + status := sdk.StatusOK + if float64(score) < threshold { + status = sdk.StatusCrit + } + return []sdk.CheckState{{ + Status: status, + Message: fmt.Sprintf("%s score: %d (min %.0f, grade %s)", r.label, score, threshold, data.Grades[r.section]), + Code: "happydeliver.score." + r.section, + Subject: r.section, + RuleName: r.Name(), + Meta: map[string]any{ + "score": score, + "grade": data.Grades[r.section], + "min": threshold, + }, + }} +} + +// Surfaces lifecycle failures in the UI when no per-section scores exist yet. +func NewLifecycleRule() sdk.CheckRule { return &lifecycleRule{} } + +type lifecycleRule struct{} + +func (r *lifecycleRule) Name() string { return "happydeliver.lifecycle" } + +func (r *lifecycleRule) Description() string { + return "Reports happyDeliver lifecycle errors (allocation, send, timeout, fetch)." +} + +func (r *lifecycleRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + var data HappyDeliverData + if err := obs.Get(ctx, ObservationKeyHappyDeliver, &data); err != nil { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("Failed to read happyDeliver observation: %v", err), + Code: "happydeliver.observation.error", + }} + } + switch data.Phase { + case "ok": + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Message: fmt.Sprintf("Message analysed in %.1fs", data.LatencySeconds), + Code: "happydeliver.lifecycle.ok", + }} + case "allocate": + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Message: "Failed to allocate a happyDeliver test address: " + data.Error, + Code: "happydeliver.api.unavailable", + }} + case "send": + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Message: "Failed to send the test email: " + data.Error, + Code: "happydeliver.send.failed", + }} + case "timeout": + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Message: "happyDeliver did not analyse the message before the timeout", + Code: "happydeliver.no_message_received", + }} + case "wait", "fetch", "parse": + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Message: "happyDeliver lifecycle error: " + data.Error, + Code: "happydeliver." + data.Phase + ".failed", + }} + default: + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Message: "happyDeliver run in unknown phase: " + data.Phase, + Code: "happydeliver.lifecycle.unknown", + }} + } +} diff --git a/checker/rule_test.go b/checker/rule_test.go new file mode 100644 index 0000000..b6d2f14 --- /dev/null +++ b/checker/rule_test.go @@ -0,0 +1,159 @@ +package checker + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type fakeObs struct { + data HappyDeliverData + err error +} + +func (f fakeObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error { + if f.err != nil { + return f.err + } + raw, err := json.Marshal(f.data) + if err != nil { + return err + } + return json.Unmarshal(raw, dest) +} + +func (fakeObs) GetRelated(context.Context, sdk.ObservationKey) ([]sdk.RelatedObservation, error) { + return nil, nil +} + +func okData() HappyDeliverData { + return HappyDeliverData{ + Phase: "ok", + Scores: map[string]int{ + SectionOverall: 90, SectionDNS: 80, SectionAuthentication: 60, + }, + Grades: map[string]string{ + SectionOverall: "A", SectionDNS: "B", SectionAuthentication: "D", + }, + } +} + +func TestSectionRuleAboveThreshold(t *testing.T) { + r := NewSectionRule(SectionOverall, "Overall", 70) + states := r.Evaluate(context.Background(), fakeObs{data: okData()}, sdk.CheckerOptions{}) + if len(states) != 1 || states[0].Status != sdk.StatusOK { + t.Fatalf("got %+v", states) + } + if !strings.Contains(states[0].Message, "score: 90") { + t.Errorf("message = %q", states[0].Message) + } + if states[0].Meta["score"] != 90 { + t.Errorf("meta score = %v", states[0].Meta["score"]) + } +} + +func TestSectionRuleBelowThresholdCRIT(t *testing.T) { + r := NewSectionRule(SectionAuthentication, "Authentication", 80) + states := r.Evaluate(context.Background(), fakeObs{data: okData()}, sdk.CheckerOptions{}) + if states[0].Status != sdk.StatusCrit { + t.Errorf("status = %v, want CRIT", states[0].Status) + } +} + +func TestSectionRuleOptionOverridesDefault(t *testing.T) { + r := NewSectionRule(SectionDNS, "DNS", 70) + // Score is 80, default threshold is 70 (OK), but we raise it to 95 -> CRIT. + states := r.Evaluate(context.Background(), fakeObs{data: okData()}, + sdk.CheckerOptions{"min_score_dns": float64(95)}) + if states[0].Status != sdk.StatusCrit { + t.Errorf("status = %v, want CRIT", states[0].Status) + } +} + +func TestSectionRuleNoReportYet(t *testing.T) { + r := NewSectionRule(SectionOverall, "Overall", 70) + states := r.Evaluate(context.Background(), fakeObs{data: HappyDeliverData{Phase: "send"}}, sdk.CheckerOptions{}) + if states[0].Status != sdk.StatusInfo || states[0].Code != "happydeliver.score.unavailable" { + t.Errorf("got %+v", states[0]) + } +} + +func TestSectionRuleScoreMissing(t *testing.T) { + r := NewSectionRule(SectionContent, "Content", 70) + states := r.Evaluate(context.Background(), fakeObs{data: okData()}, sdk.CheckerOptions{}) + if states[0].Status != sdk.StatusInfo || states[0].Code != "happydeliver.score.missing" { + t.Errorf("got %+v", states[0]) + } +} + +func TestSectionRuleObservationError(t *testing.T) { + r := NewSectionRule(SectionOverall, "Overall", 70) + states := r.Evaluate(context.Background(), fakeObs{err: errors.New("boom")}, sdk.CheckerOptions{}) + if states[0].Status != sdk.StatusError { + t.Errorf("status = %v", states[0].Status) + } + if !strings.Contains(states[0].Message, "boom") { + t.Errorf("message = %q", states[0].Message) + } +} + +func TestSectionRuleNameAndOptionsDoc(t *testing.T) { + r := NewSectionRule(SectionDNS, "DNS", 70).(*sectionRule) + if r.Name() != "happydeliver.score.dns" { + t.Errorf("Name = %q", r.Name()) + } + rwo, ok := any(r).(sdk.CheckRuleWithOptions) + if !ok { + t.Fatal("sectionRule should implement CheckRuleWithOptions") + } + doc := rwo.Options() + if len(doc.UserOpts) != 1 || doc.UserOpts[0].Id != "min_score_dns" { + t.Errorf("doc = %+v", doc) + } + if doc.UserOpts[0].Default != 70.0 { + t.Errorf("default = %v", doc.UserOpts[0].Default) + } +} + +func TestLifecycleRulePhases(t *testing.T) { + cases := []struct { + phase string + errMsg string + latency float64 + wantSt sdk.Status + wantCode string + }{ + {"ok", "", 1.5, sdk.StatusOK, "happydeliver.lifecycle.ok"}, + {"allocate", "down", 0, sdk.StatusCrit, "happydeliver.api.unavailable"}, + {"send", "auth fail", 0, sdk.StatusCrit, "happydeliver.send.failed"}, + {"timeout", "", 0, sdk.StatusWarn, "happydeliver.no_message_received"}, + {"wait", "x", 0, sdk.StatusCrit, "happydeliver.wait.failed"}, + {"fetch", "x", 0, sdk.StatusCrit, "happydeliver.fetch.failed"}, + {"parse", "bad", 0, sdk.StatusCrit, "happydeliver.parse.failed"}, + {"weird", "", 0, sdk.StatusInfo, "happydeliver.lifecycle.unknown"}, + } + r := NewLifecycleRule() + for _, tc := range cases { + t.Run(tc.phase, func(t *testing.T) { + d := HappyDeliverData{Phase: tc.phase, Error: tc.errMsg, LatencySeconds: tc.latency} + states := r.Evaluate(context.Background(), fakeObs{data: d}, sdk.CheckerOptions{}) + if states[0].Status != tc.wantSt { + t.Errorf("status = %v, want %v", states[0].Status, tc.wantSt) + } + if states[0].Code != tc.wantCode { + t.Errorf("code = %q, want %q", states[0].Code, tc.wantCode) + } + }) + } +} + +func TestLifecycleRuleObservationError(t *testing.T) { + states := NewLifecycleRule().Evaluate(context.Background(), fakeObs{err: errors.New("io")}, sdk.CheckerOptions{}) + if states[0].Status != sdk.StatusError { + t.Errorf("got %+v", states[0]) + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..87bdc5c --- /dev/null +++ b/checker/types.go @@ -0,0 +1,90 @@ +package checker + +import ( + "encoding/json" + "time" +) + +const ObservationKeyHappyDeliver = "happydeliver" + +const ( + SectionOverall = "overall" + SectionDNS = "dns" + SectionAuthentication = "authentication" + SectionSpam = "spam" + SectionBlacklist = "blacklist" + SectionHeader = "header" + SectionContent = "content" +) + +// Overall is first so metric and rule iteration is deterministic. +var AllSections = []string{ + SectionOverall, + SectionDNS, + SectionAuthentication, + SectionSpam, + SectionBlacklist, + SectionHeader, + SectionContent, +} + +// Report is stored verbatim so new upstream sections appear without an SDK change. +type HappyDeliverData struct { + Phase string `json:"phase"` + Endpoint string `json:"endpoint"` + TestID string `json:"test_id,omitempty"` + RecipientEmail string `json:"recipient_email,omitempty"` + StartedAt time.Time `json:"started_at"` + AnalysedAt time.Time `json:"analysed_at,omitempty"` + LatencySeconds float64 `json:"latency_seconds,omitempty"` + Report json.RawMessage `json:"report,omitempty"` + Scores map[string]int `json:"scores,omitempty"` + Grades map[string]string `json:"grades,omitempty"` + Error string `json:"error,omitempty"` +} + +// Minimal subset: avoids mirroring the full schema so new upstream fields don't break us. +type reportEnvelope struct { + Score int `json:"score"` + Grade string `json:"grade"` + Summary struct { + DNSScore int `json:"dns_score"` + DNSGrade string `json:"dns_grade"` + AuthenticationScore int `json:"authentication_score"` + AuthenticationGrade string `json:"authentication_grade"` + SpamScore int `json:"spam_score"` + SpamGrade string `json:"spam_grade"` + BlacklistScore int `json:"blacklist_score"` + BlacklistGrade string `json:"blacklist_grade"` + HeaderScore int `json:"header_score"` + HeaderGrade string `json:"header_grade"` + ContentScore int `json:"content_score"` + ContentGrade string `json:"content_grade"` + } `json:"summary"` +} + +func extractScores(raw json.RawMessage) (map[string]int, map[string]string, error) { + var env reportEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, nil, err + } + scores := map[string]int{ + SectionOverall: env.Score, + SectionDNS: env.Summary.DNSScore, + SectionAuthentication: env.Summary.AuthenticationScore, + SectionSpam: env.Summary.SpamScore, + SectionBlacklist: env.Summary.BlacklistScore, + SectionHeader: env.Summary.HeaderScore, + SectionContent: env.Summary.ContentScore, + } + grades := map[string]string{ + SectionOverall: env.Grade, + SectionDNS: env.Summary.DNSGrade, + SectionAuthentication: env.Summary.AuthenticationGrade, + SectionSpam: env.Summary.SpamGrade, + SectionBlacklist: env.Summary.BlacklistGrade, + SectionHeader: env.Summary.HeaderGrade, + SectionContent: env.Summary.ContentGrade, + } + return scores, grades, nil +} diff --git a/checker/types_test.go b/checker/types_test.go new file mode 100644 index 0000000..5caf5d3 --- /dev/null +++ b/checker/types_test.go @@ -0,0 +1,76 @@ +package checker + +import ( + "encoding/json" + "testing" +) + +func TestExtractScores(t *testing.T) { + raw := json.RawMessage(`{ + "score": 82, "grade": "B", + "summary": { + "dns_score": 90, "dns_grade": "A", + "authentication_score": 75, "authentication_grade": "C", + "spam_score": 88, "spam_grade": "B", + "blacklist_score": 100, "blacklist_grade": "A", + "header_score": 70, "header_grade": "C", + "content_score": 65, "content_grade": "D" + } + }`) + + scores, grades, err := extractScores(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := map[string]int{ + SectionOverall: 82, SectionDNS: 90, SectionAuthentication: 75, + SectionSpam: 88, SectionBlacklist: 100, SectionHeader: 70, SectionContent: 65, + } + for k, v := range want { + if scores[k] != v { + t.Errorf("scores[%s] = %d, want %d", k, scores[k], v) + } + } + wantGrades := map[string]string{ + SectionOverall: "B", SectionDNS: "A", SectionAuthentication: "C", + SectionSpam: "B", SectionBlacklist: "A", SectionHeader: "C", SectionContent: "D", + } + for k, v := range wantGrades { + if grades[k] != v { + t.Errorf("grades[%s] = %q, want %q", k, grades[k], v) + } + } +} + +func TestExtractScoresMissingFieldsDefaultToZero(t *testing.T) { + scores, grades, err := extractScores(json.RawMessage(`{"score": 50, "grade": "F"}`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if scores[SectionOverall] != 50 || grades[SectionOverall] != "F" { + t.Errorf("overall not extracted: %v / %v", scores[SectionOverall], grades[SectionOverall]) + } + if scores[SectionDNS] != 0 || grades[SectionDNS] != "" { + t.Errorf("missing fields should default to zero values") + } + for _, s := range AllSections { + if _, ok := scores[s]; !ok { + t.Errorf("section %q missing from scores map", s) + } + if _, ok := grades[s]; !ok { + t.Errorf("section %q missing from grades map", s) + } + } +} + +func TestExtractScoresInvalidJSON(t *testing.T) { + if _, _, err := extractScores(json.RawMessage(`{not json`)); err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestAllSectionsOverallFirst(t *testing.T) { + if len(AllSections) == 0 || AllSections[0] != SectionOverall { + t.Fatalf("AllSections must start with overall, got %v", AllSections) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c90df3 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.happydns.org/checker-happydeliver + +go 1.25.0 + +require git.happydns.org/checker-sdk-go v1.5.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c389c68 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= +git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..08c2d6a --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "flag" + "log" + + hd "git.happydns.org/checker-happydeliver/checker" + "git.happydns.org/checker-sdk-go/checker/server" +) + +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + + hd.Version = Version + + srv := server.New(hd.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..77c1527 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,13 @@ +package main + +import ( + hd "git.happydns.org/checker-happydeliver/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "custom-build" + +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + hd.Version = Version + return hd.Definition(), hd.Provider(), nil +}