Files
notify-gateway/internal/outbound/smtp.go
T
2026-09-16 06:26:16 +02:00

124 lines
3.5 KiB
Go

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
}