refactor
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
htmltmpl "html/template"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
texttmpl "text/template"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
)
|
||||
|
||||
// EmailMessage is the rendered mail payload a sender delivers.
|
||||
type EmailMessage struct {
|
||||
From mail.Address
|
||||
To []mail.Address
|
||||
Subject string
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
}
|
||||
|
||||
// EmailSender delivers an already-rendered mail and exposes its default sender.
|
||||
type EmailSender interface {
|
||||
Send(ctx context.Context, msg EmailMessage) error
|
||||
DefaultFrom() mail.Address
|
||||
}
|
||||
|
||||
// SMTPEmailSenderConfig configures the SMTP mail transport.
|
||||
type SMTPEmailSenderConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
DefaultFrom mail.Address
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type smtpEmailSender struct {
|
||||
host string
|
||||
addr string
|
||||
auth smtp.Auth
|
||||
defaultFrom mail.Address
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewSMTPEmailSender returns an SMTP-backed sender. It supports plain SMTP with
|
||||
// opportunistic STARTTLS, which is the only configured transport for now.
|
||||
func NewSMTPEmailSender(cfg SMTPEmailSenderConfig) (EmailSender, error) {
|
||||
if strings.TrimSpace(cfg.Host) == "" {
|
||||
return nil, fmt.Errorf("smtp sender: missing host")
|
||||
}
|
||||
if cfg.Port <= 0 {
|
||||
return nil, fmt.Errorf("smtp sender: invalid port %d", cfg.Port)
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
}
|
||||
if strings.TrimSpace(cfg.DefaultFrom.Address) == "" {
|
||||
return nil, fmt.Errorf("smtp sender: missing default from address")
|
||||
}
|
||||
s := &smtpEmailSender{
|
||||
host: cfg.Host,
|
||||
addr: net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)),
|
||||
defaultFrom: cfg.DefaultFrom,
|
||||
timeout: cfg.Timeout,
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
s.auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *smtpEmailSender) DefaultFrom() mail.Address { return s.defaultFrom }
|
||||
|
||||
func (s *smtpEmailSender) Send(ctx context.Context, msg EmailMessage) error {
|
||||
if strings.TrimSpace(msg.From.Address) == "" {
|
||||
return fmt.Errorf("smtp sender: missing from address")
|
||||
}
|
||||
if len(msg.To) == 0 {
|
||||
return fmt.Errorf("smtp sender: missing recipients")
|
||||
}
|
||||
if strings.TrimSpace(msg.Subject) == "" {
|
||||
return fmt.Errorf("smtp sender: missing subject")
|
||||
}
|
||||
body, err := buildSMTPMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := (&net.Dialer{Timeout: s.timeout}).DialContext(ctx, "tcp", s.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp sender: dial %s: %w", s.addr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(s.timeout))
|
||||
|
||||
client, err := smtp.NewClient(conn, s.host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp sender: create client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return fmt.Errorf("smtp sender: starttls: %w", err)
|
||||
}
|
||||
}
|
||||
if s.auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); !ok {
|
||||
return fmt.Errorf("smtp sender: server does not support auth")
|
||||
}
|
||||
if err := client.Auth(s.auth); err != nil {
|
||||
return fmt.Errorf("smtp sender: auth: %w", err)
|
||||
}
|
||||
}
|
||||
if err := client.Mail(msg.From.Address); err != nil {
|
||||
return fmt.Errorf("smtp sender: mail from %s: %w", msg.From.Address, err)
|
||||
}
|
||||
for _, rcpt := range msg.To {
|
||||
if err := client.Rcpt(rcpt.Address); err != nil {
|
||||
return fmt.Errorf("smtp sender: rcpt to %s: %w", rcpt.Address, err)
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp sender: data: %w", err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
_ = w.Close()
|
||||
return fmt.Errorf("smtp sender: write body: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("smtp sender: close body: %w", err)
|
||||
}
|
||||
if err := client.Quit(); err != nil {
|
||||
return fmt.Errorf("smtp sender: quit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSMTPMessage(msg EmailMessage) ([]byte, error) {
|
||||
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
|
||||
return nil, fmt.Errorf("email: missing body")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeHeader := func(name, value string) {
|
||||
buf.WriteString(name)
|
||||
buf.WriteString(": ")
|
||||
buf.WriteString(value)
|
||||
buf.WriteString("\r\n")
|
||||
}
|
||||
writeHeader("From", msg.From.String())
|
||||
toList := make([]string, 0, len(msg.To))
|
||||
for _, rcpt := range msg.To {
|
||||
toList = append(toList, rcpt.String())
|
||||
}
|
||||
writeHeader("To", strings.Join(toList, ", "))
|
||||
writeHeader("Subject", mime.QEncoding.Encode("utf-8", msg.Subject))
|
||||
writeHeader("Date", time.Now().Format(time.RFC1123Z))
|
||||
writeHeader("MIME-Version", "1.0")
|
||||
|
||||
switch {
|
||||
case msg.TextBody != "" && msg.HTMLBody != "":
|
||||
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
|
||||
writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, boundary))
|
||||
buf.WriteString("\r\n")
|
||||
writeMIMEPart(&buf, boundary, "text/plain; charset=utf-8", msg.TextBody)
|
||||
writeMIMEPart(&buf, boundary, "text/html; charset=utf-8", msg.HTMLBody)
|
||||
buf.WriteString("--")
|
||||
buf.WriteString(boundary)
|
||||
buf.WriteString("--\r\n")
|
||||
case msg.HTMLBody != "":
|
||||
writeHeader("Content-Type", `text/html; charset=utf-8`)
|
||||
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
||||
buf.WriteString("\r\n")
|
||||
if err := writeQuotedPrintable(&buf, msg.HTMLBody); err != nil {
|
||||
return nil, fmt.Errorf("email: encode html body: %w", err)
|
||||
}
|
||||
default:
|
||||
writeHeader("Content-Type", `text/plain; charset=utf-8`)
|
||||
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
||||
buf.WriteString("\r\n")
|
||||
if err := writeQuotedPrintable(&buf, msg.TextBody); err != nil {
|
||||
return nil, fmt.Errorf("email: encode text body: %w", err)
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeMIMEPart(buf *bytes.Buffer, boundary, contentType, body string) {
|
||||
buf.WriteString("--")
|
||||
buf.WriteString(boundary)
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString("Content-Type: ")
|
||||
buf.WriteString(contentType)
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||
_ = writeQuotedPrintable(buf, body)
|
||||
buf.WriteString("\r\n")
|
||||
}
|
||||
|
||||
func writeQuotedPrintable(buf *bytes.Buffer, body string) error {
|
||||
w := quotedprintable.NewWriter(buf)
|
||||
if _, err := w.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n"))); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// EmailTemplate is a named mail template, typically selected by a flow hook.
|
||||
type EmailTemplate struct {
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text,omitempty"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
}
|
||||
|
||||
func (t EmailTemplate) validate(name string) error {
|
||||
if strings.TrimSpace(t.Subject) == "" {
|
||||
return fmt.Errorf("email template %q: missing subject", name)
|
||||
}
|
||||
if strings.TrimSpace(t.Text) == "" && strings.TrimSpace(t.HTML) == "" {
|
||||
return fmt.Errorf("email template %q: missing text/html body", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmailTemplateStore resolves a template by name.
|
||||
type EmailTemplateStore interface {
|
||||
Get(name string) (EmailTemplate, error)
|
||||
}
|
||||
|
||||
type emailTemplateCatalog struct {
|
||||
templates map[string]EmailTemplate
|
||||
}
|
||||
|
||||
func newEmailTemplateCatalog(seed map[string]EmailTemplate) *emailTemplateCatalog {
|
||||
out := &emailTemplateCatalog{templates: make(map[string]EmailTemplate, len(seed))}
|
||||
for name, tmpl := range seed {
|
||||
out.templates[name] = tmpl
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *emailTemplateCatalog) Get(name string) (EmailTemplate, error) {
|
||||
tmpl, ok := c.templates[name]
|
||||
if !ok {
|
||||
return EmailTemplate{}, fmt.Errorf("unknown template %q", name)
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
//go:embed email_templates/*.json
|
||||
var embeddedEmailTemplateFS embed.FS
|
||||
|
||||
// LoadEmailTemplates returns the embedded templates overlaid by any JSON files in
|
||||
// dir (same precedence model as flow definitions: on-disk wins).
|
||||
func LoadEmailTemplates(dir string) (EmailTemplateStore, error) {
|
||||
templates := map[string]EmailTemplate{}
|
||||
|
||||
matches, err := fs.Glob(embeddedEmailTemplateFS, "email_templates/*.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list embedded email templates: %w", err)
|
||||
}
|
||||
for _, match := range matches {
|
||||
data, err := embeddedEmailTemplateFS.ReadFile(match)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded email template %s: %w", match, err)
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(match), ".json")
|
||||
tmpl, err := parseEmailTemplate(name, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[name] = tmpl
|
||||
}
|
||||
|
||||
if dir != "" {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list email templates in %s: %w", dir, err)
|
||||
}
|
||||
for _, file := range files {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read email template %s: %w", file, err)
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(file), ".json")
|
||||
tmpl, err := parseEmailTemplate(name, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[name] = tmpl
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("stat email template dir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return newEmailTemplateCatalog(templates), nil
|
||||
}
|
||||
|
||||
func parseEmailTemplate(name string, data []byte) (EmailTemplate, error) {
|
||||
var tmpl EmailTemplate
|
||||
if err := json.Unmarshal(data, &tmpl); err != nil {
|
||||
return EmailTemplate{}, fmt.Errorf("parse email template %q: %w", name, err)
|
||||
}
|
||||
if err := tmpl.validate(name); err != nil {
|
||||
return EmailTemplate{}, err
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
type emailHookParams struct {
|
||||
Template string `json:"template"`
|
||||
To string `json:"to,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
}
|
||||
|
||||
type flowTemplateData struct {
|
||||
Order *OrderGrain
|
||||
OrderID string
|
||||
Step string
|
||||
Phase flow.Phase
|
||||
Error string
|
||||
Vars map[string]any
|
||||
Fulfillment *Fulfillment
|
||||
ShippingAddress any
|
||||
BillingAddress any
|
||||
}
|
||||
|
||||
// RegisterEmailHook registers the "send_email" flow hook. It reads the current
|
||||
// order for template data, renders the named template, and delivers it through
|
||||
// the configured sender. Hook params:
|
||||
//
|
||||
// {
|
||||
// "template": "fulfillment-shipped",
|
||||
// "to": "{{ .Order.CustomerEmail }}",
|
||||
// "from": "Warehouse <warehouse@example.com>"
|
||||
// }
|
||||
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore) {
|
||||
reg.HookWithMeta("send_email", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||
if sender == nil {
|
||||
return fmt.Errorf("send_email: no sender configured")
|
||||
}
|
||||
if templates == nil {
|
||||
return fmt.Errorf("send_email: no template store configured")
|
||||
}
|
||||
var p emailHookParams
|
||||
if len(params) > 0 {
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return fmt.Errorf("send_email: bad params: %w", err)
|
||||
}
|
||||
}
|
||||
if p.Template == "" {
|
||||
return fmt.Errorf("send_email: missing template")
|
||||
}
|
||||
o, err := app.Get(ctx, st.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpl, err := templates.Get(p.Template)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: %w", err)
|
||||
}
|
||||
data := buildFlowTemplateData(o, st, info)
|
||||
|
||||
subjectSource := tmpl.Subject
|
||||
if p.Subject != "" {
|
||||
subjectSource = p.Subject
|
||||
}
|
||||
subject, err := renderTextTemplate("email-subject", subjectSource, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render subject: %w", err)
|
||||
}
|
||||
textBody, err := renderOptionalTextTemplate("email-text", tmpl.Text, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render text body: %w", err)
|
||||
}
|
||||
htmlBody, err := renderOptionalHTMLTemplate("email-html", tmpl.HTML, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render html body: %w", err)
|
||||
}
|
||||
|
||||
toSource := p.To
|
||||
if strings.TrimSpace(toSource) == "" {
|
||||
toSource = o.CustomerEmail
|
||||
}
|
||||
toRendered, err := renderTextTemplate("email-to", toSource, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render recipient: %w", err)
|
||||
}
|
||||
recipients, err := mail.ParseAddressList(toRendered)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: parse recipient %q: %w", toRendered, err)
|
||||
}
|
||||
to := make([]mail.Address, 0, len(recipients))
|
||||
for _, rcpt := range recipients {
|
||||
to = append(to, *rcpt)
|
||||
}
|
||||
|
||||
from := sender.DefaultFrom()
|
||||
if strings.TrimSpace(p.From) != "" {
|
||||
fromRendered, err := renderTextTemplate("email-from", p.From, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render sender: %w", err)
|
||||
}
|
||||
parsed, err := mail.ParseAddress(strings.TrimSpace(fromRendered))
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: parse sender %q: %w", fromRendered, err)
|
||||
}
|
||||
from = *parsed
|
||||
}
|
||||
|
||||
return sender.Send(ctx, EmailMessage{
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
TextBody: textBody,
|
||||
HTMLBody: htmlBody,
|
||||
})
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Render a named email template and send it through the configured sender.",
|
||||
ExampleParams: json.RawMessage(`{"template":"fulfillment-shipped","to":"{{ .Order.CustomerEmail }}","from":"Store <no-reply@example.com>"}`),
|
||||
})
|
||||
}
|
||||
|
||||
func buildFlowTemplateData(o *OrderGrain, st *flow.State, info flow.HookInfo) flowTemplateData {
|
||||
errStr := ""
|
||||
if info.Err != nil {
|
||||
errStr = info.Err.Error()
|
||||
}
|
||||
var lastFulfillment *Fulfillment
|
||||
if n := len(o.Fulfillments); n > 0 {
|
||||
lastFulfillment = &o.Fulfillments[n-1]
|
||||
}
|
||||
return flowTemplateData{
|
||||
Order: o,
|
||||
OrderID: OrderId(st.ID).String(),
|
||||
Step: info.Step,
|
||||
Phase: info.Phase,
|
||||
Error: errStr,
|
||||
Vars: st.Vars,
|
||||
Fulfillment: lastFulfillment,
|
||||
ShippingAddress: decodeEmailTemplateJSON(o.ShippingAddress),
|
||||
BillingAddress: decodeEmailTemplateJSON(o.BillingAddress),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeEmailTemplateJSON(raw json.RawMessage) any {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func renderTextTemplate(name, src string, data any) (string, error) {
|
||||
tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
|
||||
func renderOptionalTextTemplate(name, src string, data any) (string, error) {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return "", nil
|
||||
}
|
||||
return renderTextTemplate(name, src, data)
|
||||
}
|
||||
|
||||
func renderOptionalHTMLTemplate(name, src string, data any) (string, error) {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return "", nil
|
||||
}
|
||||
tmpl, err := htmltmpl.New(name).Option("missingkey=error").Parse(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
Reference in New Issue
Block a user