checker-happydeliver/checker/collect.go

390 lines
11 KiB
Go

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