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