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() }