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 " // } 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 "}`), }) } 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 }