package outbound import ( "context" "crypto/tls" "encoding/base64" "errors" "fmt" "mime" "net" "net/smtp" "net/textproto" "strconv" "strings" "time" "github.com/example/notify-gateway/internal/config" ) func mailBytes(o config.OutboundConfig, p map[string]any, id string) []byte { title, _ := p["title"].(string) message, _ := p["message"].(string) title = strings.NewReplacer("\r", " ", "\n", " ").Replace(title) // Force base64 for non-ASCII bodies and wrap per MIME line-length limits. encoded := base64.StdEncoding.EncodeToString([]byte(message)) var body strings.Builder for len(encoded) > 76 { body.WriteString(encoded[:76] + "\r\n") encoded = encoded[76:] } body.WriteString(encoded + "\r\n") headers := []string{"From: " + o.From, "To: " + strings.Join(o.To, ", "), "Subject: " + mime.QEncoding.Encode("utf-8", title), "Date: " + time.Now().Format(time.RFC1123Z), "MIME-Version: 1.0", "Content-Type: text/plain; charset=utf-8", "Content-Transfer-Encoding: base64", "Auto-Submitted: auto-generated", "X-Notify-Gateway: 1"} if id != "" { headers = append(headers, "Message-ID: <"+id+"@notify-gateway.local>") } return []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + body.String()) } func sendSMTP(ctx context.Context, o config.OutboundConfig, p map[string]any, id string) (resp Response, err error) { return sendSMTPWithTLS(ctx, o, p, id, &tls.Config{ServerName: o.SMTPHost, MinVersion: tls.VersionTLS12}) } func sendSMTPWithTLS(ctx context.Context, o config.OutboundConfig, p map[string]any, id string, tlsConfig *tls.Config) (resp Response, err error) { defer func() { if err != nil { var e *textproto.Error if errors.As(err, &e) { resp.StatusCode = e.Code resp.Retryable = e.Code >= 400 && e.Code < 500 } else { resp.Retryable = true } err = errors.New("SMTP delivery failed") } }() password, err := config.Secret(o.Password) if err != nil { return Response{}, err } timeout := o.TimeoutS if timeout == 0 { timeout = 15 } dialer := net.Dialer{Timeout: time.Duration(timeout) * time.Second} address := net.JoinHostPort(o.SMTPHost, strconv.Itoa(o.SMTPPort)) conn, err := dialer.DialContext(ctx, "tcp", address) if err != nil { return resp, err } defer conn.Close() rawConn := conn stop := context.AfterFunc(ctx, func() { rawConn.Close() }) defer stop() if err = conn.SetDeadline(time.Now().Add(time.Duration(timeout) * time.Second)); err != nil { return resp, err } if o.TLSMode == "tls" { secure := tls.Client(conn, tlsConfig) if err = secure.HandshakeContext(ctx); err != nil { return resp, err } conn = secure } c, err := smtp.NewClient(conn, o.SMTPHost) if err != nil { return resp, err } defer c.Close() if o.TLSMode == "starttls" { if ok, _ := c.Extension("STARTTLS"); !ok { return resp, fmt.Errorf("STARTTLS required") } if err = c.StartTLS(tlsConfig); err != nil { return resp, err } } if o.Username != "" { if err = c.Auth(smtp.PlainAuth("", o.Username, password, o.SMTPHost)); err != nil { return resp, err } } if err = c.Mail(o.From); err != nil { return resp, err } for _, to := range o.To { if err = c.Rcpt(to); err != nil { return resp, err } } w, err := c.Data() if err != nil { return resp, err } if _, err = w.Write(mailBytes(o, p, id)); err != nil { return resp, err } if err = w.Close(); err != nil { return resp, err } // DATA has been acknowledged. A failed QUIT must not cause another delivery. _ = c.Quit() return Response{StatusCode: 250}, nil }