201 lines
5.4 KiB
Go
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()
|
|
}
|