Initial commit
This commit is contained in:
commit
5ffc3ab4df
19 changed files with 2095 additions and 0 deletions
390
checker/collect.go
Normal file
390
checker/collect.go
Normal 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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue