390 lines
11 KiB
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"
|
|
}
|