Initial commit

This commit is contained in:
nemunaire 2026-04-27 01:07:57 +07:00
commit 3264e54723
20 changed files with 2269 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-happydeliver
checker-happydeliver.so

17
Dockerfile Normal file
View file

@ -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"]

21
LICENSE Normal file
View file

@ -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.

28
Makefile Normal file
View file

@ -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

26
NOTICE Normal file
View file

@ -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

64
README.md Normal file
View file

@ -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_<section>`| 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=<name>`
(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
```

390
checker/collect.go Normal file
View file

@ -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 = `<!doctype html><html><body><p>This is an automated deliverability test sent by <strong>happyDomain</strong> via <em>happyDeliver</em>.</p><p>You can ignore this message.</p></body></html>`
)
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"
}

691
checker/collect_test.go Normal file
View file

@ -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 != "<test@example.org>" {
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 <alice@example.org>"
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, "<alice@example.org>") {
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 <alice@example.org>",
Subject: "Hello: accents",
BodyText: "plain body",
BodyHTML: "<p>html body</p>",
}
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: "<a@b.test>",
Subject: "s", BodyText: "no newline", BodyHTML: "<p>x</p>",
}
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)
}
}

157
checker/definition.go Normal file
View file

@ -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
}

174
checker/interactive.go Normal file
View file

@ -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
}

47
checker/provider.go Normal file
View file

@ -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
}

138
checker/provider_test.go Normal file
View file

@ -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.<section>.
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)
}
}

145
checker/rule.go Normal file
View file

@ -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 &sectionRule{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",
}}
}
}

159
checker/rule_test.go Normal file
View file

@ -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])
}
}

90
checker/types.go Normal file
View file

@ -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
}

76
checker/types_test.go Normal file
View file

@ -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)
}
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module git.happydns.org/checker-happydeliver
go 1.25.0
require git.happydns.org/checker-sdk-go v1.5.0

2
go.sum Normal file
View file

@ -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=

24
main.go Normal file
View file

@ -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)
}
}

13
plugin/plugin.go Normal file
View file

@ -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
}