Files

201 lines
5.4 KiB
Go

package notify
import "bytes"
import "context"
import "crypto/tls"
import "encoding/json"
import "fmt"
import "io"
import "net"
import "net/smtp"
import "strconv"
import "strings"
import "time"
import "codit/internal/models"
// smtpConfig is the non-secret transport config for an SMTP email transport.
// Security is starttls|smtps|none; the password comes from the secret blob.
type smtpConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Security string `json:"security"`
InsecureSkipVerify bool `json:"insecure_skip_verify"`
CARef string `json:"ca_ref"`
From string `json:"from"`
Username string `json:"username"`
}
// parseRecipients splits a channel address into individual email recipients,
// accepting comma or semicolon separators and trimming blanks.
func parseRecipients(address string) []string {
var out []string
var part string
var fields []string
fields = strings.FieldsFunc(address, func(r rune) bool { return r == ',' || r == ';' })
for _, part = range fields {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
// headerSafe strips CR/LF so a value can't inject additional headers.
func headerSafe(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, "\r", " "), "\n", " ")
}
// buildEmailMessage assembles a minimal RFC 5322 text/plain message. Header
// values are CR/LF-stripped to prevent header injection.
func buildEmailMessage(from string, recipients []string, subject string, body string, sent time.Time) []byte {
var buf bytes.Buffer
var safeRecipients []string
var r string
for _, r = range recipients {
safeRecipients = append(safeRecipients, headerSafe(r))
}
buf.WriteString("From: " + headerSafe(from) + "\r\n")
buf.WriteString("To: " + strings.Join(safeRecipients, ", ") + "\r\n")
buf.WriteString("Subject: " + headerSafe(subject) + "\r\n")
buf.WriteString("Date: " + sent.Format(time.RFC1123Z) + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
buf.WriteString("\r\n")
buf.WriteString(body)
if !strings.HasSuffix(body, "\n") {
buf.WriteString("\r\n")
}
return buf.Bytes()
}
// deliverSMTP sends the event as an email to one or more recipients. It dials
// through the egress policy (SSRF guard), honours STARTTLS/SMTPS with the shared
// CA-pinning TLS config (fail-closed on a missing CA), and authenticates with
// the stored password when a username is set.
func (d *Dispatcher) deliverSMTP(ctx context.Context, target models.DeliveryTarget, secrets map[string]string, event models.Event) error {
var cfg smtpConfig
var recipients []string
var port int
var security string
var useTLS bool
var tlsCfg *tls.Config
var conn net.Conn
var client *smtp.Client
var deadline time.Time
var rcpt string
var wc io.WriteCloser
var err error
if strings.TrimSpace(target.Config) != "" {
_ = json.Unmarshal([]byte(target.Config), &cfg)
}
if strings.TrimSpace(cfg.Host) == "" {
return fmt.Errorf("smtp channel %q: transport has no host", target.ChannelName)
}
if strings.TrimSpace(cfg.From) == "" {
return fmt.Errorf("smtp channel %q: transport has no from address", target.ChannelName)
}
recipients = parseRecipients(target.Address)
if len(recipients) == 0 {
return fmt.Errorf("smtp channel %q has no recipients", target.ChannelName)
}
security = strings.TrimSpace(cfg.Security)
if security == "" {
security = "starttls"
}
port = cfg.Port
if port <= 0 {
if security == "smtps" {
port = 465
} else {
port = 587
}
}
useTLS = security == "smtps" || security == "starttls"
if useTLS {
// fail-closed if a pinned CA can't be resolved (deleted / bad id)
tlsCfg, err = d.tlsConfigFor(cfg.InsecureSkipVerify, cfg.CARef)
if err != nil {
return err
}
if tlsCfg == nil {
tlsCfg = &tls.Config{}
}
tlsCfg.ServerName = cfg.Host
}
conn, err = d.policy.DialContext(ctx, "tcp", net.JoinHostPort(cfg.Host, strconv.Itoa(port)))
if err != nil {
return err
}
// Bound the rest of the (context-unaware) net/smtp conversation by the
// per-call delivery timeout, not exceeding any deadline already on ctx.
deadline = time.Now().Add(d.webhookTimeout())
if dl, hasDL := ctx.Deadline(); hasDL && dl.Before(deadline) {
deadline = dl
}
_ = conn.SetDeadline(deadline)
if security == "smtps" {
var tlsConn *tls.Conn
tlsConn = tls.Client(conn, tlsCfg)
err = tlsConn.HandshakeContext(ctx)
if err != nil {
conn.Close()
return err
}
conn = tlsConn
}
client, err = smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return err
}
defer client.Close()
if security == "starttls" {
err = client.StartTLS(tlsCfg)
if err != nil {
return err
}
}
if strings.TrimSpace(cfg.Username) != "" {
var auth smtp.Auth
auth = smtp.PlainAuth("", cfg.Username, secrets["password"], cfg.Host)
err = client.Auth(auth)
if err != nil {
return err
}
}
err = client.Mail(cfg.From)
if err != nil {
return err
}
for _, rcpt = range recipients {
err = client.Rcpt(rcpt)
if err != nil {
return err
}
}
wc, err = client.Data()
if err != nil {
return err
}
_, err = wc.Write(buildEmailMessage(cfg.From, recipients, d.withOriginTitle(event).Title, event.Body, time.Unix(event.CreatedAt, 0).UTC()))
if err != nil {
wc.Close()
return err
}
err = wc.Close()
if err != nil {
return err
}
return client.Quit()
}