Use Sendico SMTP mailbox for local notifications (#57)

This commit is contained in:
Stephan D committed 2026-10-17 20:51:28 +02:00
1 parent 3e4e496037
commit b36eb72c23
18 files changed
+528 -36

No files matched your search

+5
View File
@@ -19,6 +19,7 @@ type Driver string
const (
SendGrid Driver = "sendgrid"
Resend Driver = "resend"
SMTP Driver = "smtp"
)
type Config = sharedmodel.DriverConfig[Driver]
@@ -35,6 +36,7 @@ type Outcome struct {
RetryAfter time.Duration
// Provider diagnostics are sanitized operational data, not public failures.
HTTPStatus int
SMTPStatus int
ProviderError string
ProviderMessage string
}
@@ -50,6 +52,9 @@ type client struct {
}
func New(config Config) (Sender, error) {
if config.Driver == SMTP {
return newSMTP(config)
}
var s Settings
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
if err != nil {
+4 -2
View File
@@ -46,7 +46,6 @@ func providerFailure(body, request []byte, secrets ...string) (string, string) {
secrets = append(secrets, numbers.FindAllString(value, -1)...)
}
}
sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) })
name := safeErrorText(dto.Name, secrets)
if !errorName.MatchString(name) {
name = ""
@@ -76,11 +75,14 @@ func requestStrings(value any) []string {
}
func safeErrorText(text string, secrets []string) string {
sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) })
var replacements []string
for _, secret := range secrets {
if secret != "" {
text = strings.ReplaceAll(text, secret, "[redacted]")
replacements = append(replacements, secret, "[redacted]")
}
}
text = strings.NewReplacer(replacements...).Replace(text)
text = addresses.ReplaceAllString(text, "[redacted-address]")
text = urls.ReplaceAllString(text, "[redacted-url]")
text = tokens.ReplaceAllString(text, "[redacted-token]")
+49
View File
@@ -0,0 +1,49 @@
package email
import (
"bytes"
"crypto/sha256"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
)
func smtpMessage(s smtpSettings, key string, email model.Email) ([]byte, error) {
if !ValidAddress(email.Address) || strings.ContainsAny(email.Subject, "\r\n") || (email.Text == "" && email.HTML == "") {
return nil, merrors.InvalidArgument("invalid email content")
}
var body bytes.Buffer
parts := multipart.NewWriter(&body)
for _, part := range []struct{ kind, value string }{{"text/plain", email.Text}, {"text/html", email.HTML}} {
if part.value == "" {
continue
}
writer, err := parts.CreatePart(textproto.MIMEHeader{"Content-Type": {part.kind + "; charset=utf-8"}, "Content-Transfer-Encoding": {"quoted-printable"}})
if err != nil {
return nil, merrors.InternalWrap(err, "create email MIME part")
}
encoded := quotedprintable.NewWriter(writer)
if _, err = encoded.Write([]byte(part.value)); err != nil {
return nil, merrors.InternalWrap(err, "encode email MIME part")
}
if err = encoded.Close(); err != nil {
return nil, merrors.InternalWrap(err, "finish email MIME part")
}
}
if err := parts.Close(); err != nil {
return nil, merrors.InternalWrap(err, "finish email MIME body")
}
from := (&mail.Address{Name: s.SenderName, Address: s.SenderAddress}).String()
to := (&mail.Address{Address: email.Address}).String()
domain := s.SenderAddress[strings.LastIndex(s.SenderAddress, "@")+1:]
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%x@%s>\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=%q\r\n\r\n", from, to, mime.QEncoding.Encode("utf-8", email.Subject), time.Now().UTC().Format(time.RFC1123Z), sha256.Sum256([]byte(key)), domain, parts.Boundary())
return append([]byte(headers), body.Bytes()...), nil
}
+122
View File
@@ -0,0 +1,122 @@
package email
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"io"
"net"
"net/smtp"
"net/textproto"
"os"
"strconv"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
"github.com/go-viper/mapstructure/v2"
)
type smtpSettings struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
PasswordEnv string `mapstructure:"password_env"`
SenderAddress string `mapstructure:"sender_address"`
SenderName string `mapstructure:"sender_name"`
NetworkTimeout int `mapstructure:"network_timeout"`
}
type smtpSender struct {
settings smtpSettings
password string
tls *tls.Config
}
func newSMTP(config Config) (Sender, error) {
var s smtpSettings
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
if err != nil {
return nil, merrors.InternalWrap(err, "create SMTP settings decoder")
}
if decoder.Decode(config.Settings) != nil || s.Host == "" || strings.ContainsAny(s.Host, " /@\r\n\t") || s.Port < 1 || s.Port > 65535 || !ValidAddress(s.Username) || !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") || s.NetworkTimeout < 1 || s.NetworkTimeout > 20 {
return nil, merrors.InvalidArgument("invalid SMTP settings")
}
password := os.Getenv(s.PasswordEnv)
if s.PasswordEnv == "" || password == "" || strings.ContainsAny(password, "\r\n\x00") {
return nil, merrors.FailedPrecondition("SMTP password environment variable is missing or invalid")
}
return &smtpSender{settings: s, password: password, tls: &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}}, nil
}
// SMTP uses a fresh authenticated TLS connection per attempt. No automatic
// resend follows an uncertain result, since SMTP has no idempotency guarantee.
func (s *smtpSender) Send(ctx context.Context, key string, email model.Email) Outcome {
body, err := smtpMessage(s.settings, key, email)
if err != nil {
return Outcome{State: model.Failed, Code: "invalid_payload"}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(s.settings.NetworkTimeout)*time.Second)
defer cancel()
dialer := tls.Dialer{NetDialer: &net.Dialer{}, Config: s.tls}
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(s.settings.Host, strconv.Itoa(s.settings.Port)))
if err != nil {
return s.failure(err, key, email)
}
defer conn.Close()
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
deadline, _ := ctx.Deadline()
if err = conn.SetDeadline(deadline); err != nil {
return s.failure(err, key, email)
}
client, err := smtp.NewClient(conn, s.settings.Host)
if err == nil {
err = client.Auth(smtp.PlainAuth("", s.settings.Username, s.password, s.settings.Host))
}
if err == nil {
err = client.Mail(s.settings.SenderAddress)
}
if err == nil {
err = client.Rcpt(email.Address)
}
if err != nil {
return s.failure(err, key, email)
}
writer, err := client.Data()
if err != nil {
return s.failure(err, key, email)
}
if _, err = io.Copy(writer, bytes.NewReader(body)); err == nil {
err = writer.Close()
}
if err != nil {
return s.failure(err, key, email)
}
// DATA's final 250 is acceptance. A later QUIT error cannot undo it.
_ = client.Quit()
return Outcome{State: model.Accepted, SMTPStatus: 250}
}
func (s *smtpSender) failure(err error, key string, email model.Email) Outcome {
secrets := []string{s.password, s.settings.Username, key, email.Address, email.Subject, email.HTML, email.Text}
secrets = append(secrets, base64.StdEncoding.EncodeToString([]byte("\x00"+s.settings.Username+"\x00"+s.password)))
secrets = append(secrets, numbers.FindAllString(email.Text+" "+email.HTML, -1)...)
out := Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderError: "smtp", ProviderMessage: safeErrorText(err.Error(), secrets)}
var reply *textproto.Error
if errors.As(err, &reply) {
out.SMTPStatus = reply.Code
if reply.Code >= 400 && reply.Code < 500 {
out.State, out.Code, out.RetryAfter = model.RetryWait, "provider_temporarily_unavailable", time.Minute
} else if reply.Code >= 500 && reply.Code < 600 {
out.State, out.Code = model.Failed, "provider_rejected"
if reply.Code == 530 || reply.Code == 534 || reply.Code == 535 || reply.Code == 538 {
out.Code = "provider_authentication_failed"
}
}
}
return out
}
+223
View File
@@ -0,0 +1,223 @@
package email
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http/httptest"
"net/mail"
"net/textproto"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"git.sendico.io/tech/sxchnge/notifications/model"
)
type smtpFixture struct {
settings smtpSettings
root *x509.CertPool
body chan []byte
auth atomic.Bool
}
func smtpServer(t *testing.T, reject string, code int) *smtpFixture {
t.Helper()
certServer := httptest.NewTLSServer(nil)
cert := certServer.TLS.Certificates[0]
root := x509.NewCertPool()
root.AddCert(certServer.Certificate())
certServer.Close()
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
host, port, _ := net.SplitHostPort(listener.Addr().String())
n, _ := strconv.Atoi(port)
f := &smtpFixture{settings: smtpSettings{Host: host, Port: n, Username: "sender@example.com", SenderAddress: "sender@example.com", SenderName: "SXCHNGE", NetworkTimeout: 2}, root: root, body: make(chan []byte, 1)}
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
if reject == "greeting" {
_ = conn.(*tls.Conn).Handshake()
_, _ = io.Copy(io.Discard, conn)
return
}
c := textproto.NewConn(conn)
if c.PrintfLine("220 localhost ESMTP") != nil {
return
}
for {
line, err := c.ReadLine()
if err != nil {
return
}
command := strings.SplitN(line, " ", 2)[0]
if command == reject {
_ = c.PrintfLine("%d rejected recipient user@example.com credential test-password", code)
continue
}
switch command {
case "EHLO":
_ = c.PrintfLine("250-localhost\r\n250 AUTH PLAIN")
case "AUTH":
want := "AUTH PLAIN " + base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00test-password"))
f.auth.Store(line == want)
_ = c.PrintfLine("235 authenticated")
case "MAIL", "RCPT":
_ = c.PrintfLine("250 OK")
case "DATA":
_ = c.PrintfLine("354 send message")
body, err := c.ReadDotBytes()
if err != nil {
return
}
f.body <- body
if reject == "final" {
_ = c.PrintfLine("%d final rejection", code)
} else if reject == "disconnect" {
return
} else {
_ = c.PrintfLine("250 queued")
}
case "QUIT":
// A lost QUIT reply must not turn accepted DATA into UNKNOWN.
return
default:
_ = c.PrintfLine("500 unexpected command")
}
}
}()
return f
}
func (f *smtpFixture) sender() *smtpSender {
return &smtpSender{settings: f.settings, password: "test-password", tls: &tls.Config{ServerName: f.settings.Host, RootCAs: f.root, MinVersion: tls.VersionTLS12}}
}
func TestSMTPAcceptanceAndMIME(t *testing.T) {
f := smtpServer(t, "", 0)
sender := f.sender()
email := model.Email{Address: "user@example.com", Subject: "Подтвердите почту", Text: "Confirm code 123456\n.line", HTML: "<p>Confirm code 123456</p>"}
out := sender.Send(context.Background(), "notification-id", email)
if out.State != model.Accepted || out.SMTPStatus != 250 || !f.auth.Load() {
t.Fatal("SMTP acceptance/authentication failed", out)
}
msg, err := mail.ReadMessage(strings.NewReader(string(<-f.body)))
if err != nil {
t.Fatal(err)
}
subject, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject"))
if err != nil || subject != email.Subject {
t.Fatal("wrong MIME subject", subject, err)
}
if msg.Header.Get("From") != "\"SXCHNGE\" <sender@example.com>" || !strings.HasSuffix(msg.Header.Get("Message-ID"), "@example.com>") {
t.Fatal("invalid sender/message identifier")
}
kind, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil || kind != "multipart/alternative" {
t.Fatal("wrong MIME type", kind, err)
}
parts := multipart.NewReader(msg.Body, params["boundary"])
for _, want := range []string{email.Text, email.HTML} {
part, err := parts.NextPart()
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(part)
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != want {
t.Fatal("incorrect MIME content", err)
}
}
}
func TestSMTPFailureSafety(t *testing.T) {
for _, tc := range []struct {
stage string
code int
state model.State
failure string
}{
{"AUTH", 535, model.Failed, "provider_authentication_failed"},
{"RCPT", 550, model.Failed, "provider_rejected"},
{"final", 451, model.RetryWait, "provider_temporarily_unavailable"},
{"disconnect", 0, model.Unknown, "provider_outcome_unknown"},
} {
t.Run(fmt.Sprintf("%s-%d", tc.stage, tc.code), func(t *testing.T) {
f := smtpServer(t, tc.stage, tc.code)
out := f.sender().Send(context.Background(), "id", model.Email{Address: "user@example.com", Subject: "Subject", Text: "Body"})
if out.State != tc.state || out.Code != tc.failure || out.SMTPStatus != tc.code {
t.Fatal("incorrect SMTP classification", out)
}
if strings.Contains(out.ProviderMessage, "test-password") || strings.Contains(out.ProviderMessage, "user@example.com") {
t.Fatal("SMTP rejection leaked private data")
}
if tc.code == 451 && out.RetryAfter <= 0 {
t.Fatal("temporary rejection did not schedule retry")
}
})
}
}
func TestSMTPCertificateAndCancellation(t *testing.T) {
t.Run("untrusted certificate", func(t *testing.T) {
f := smtpServer(t, "", 0)
sender := f.sender()
sender.tls.RootCAs = x509.NewCertPool()
if out := sender.Send(context.Background(), "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || f.auth.Load() {
t.Fatal("untrusted TLS accepted", out)
}
})
t.Run("cancel blocked greeting", func(t *testing.T) {
f := smtpServer(t, "greeting", 0)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
started := time.Now()
if out := f.sender().Send(ctx, "id", model.Email{Address: "user@example.com", Text: "Body"}); out.State != model.Unknown || time.Since(started) > time.Second {
t.Fatal("SMTP ignored cancellation", out)
}
})
}
func TestSMTPConfigAndHeaderValidation(t *testing.T) {
t.Setenv("TEST_SMTP_PASSWORD", "test-password")
valid := map[string]any{"host": "mail.example.com", "port": 465, "username": "sender@example.com", "password_env": "TEST_SMTP_PASSWORD", "sender_address": "sender@example.com", "network_timeout": 10}
if _, err := New(Config{Driver: SMTP, Settings: valid}); err != nil {
t.Fatal(err)
}
for field, value := range map[string]any{"port": 0, "host": "https://bad", "username": "bad", "network_timeout": 0, "password_env": "MISSING_SMTP_PASSWORD", "typo": true} {
settings := map[string]any{}
for key, item := range valid {
settings[key] = item
}
settings[field] = value
if _, err := New(Config{Driver: SMTP, Settings: settings}); err == nil {
t.Fatalf("invalid setting %s accepted", field)
}
}
if _, err := smtpMessage(smtpSettings{}, "id", model.Email{Address: "user@example.com", Subject: "Subject\r\nBcc: stolen@example.com", Text: "Body"}); err == nil {
t.Fatal("header injection accepted")
}
}
func TestSMTPAuthenticationDiagnosticRedactsEncodedCredential(t *testing.T) {
f := &smtpSender{settings: smtpSettings{Username: "sender@example.com"}, password: "a"}
auth := base64.StdEncoding.EncodeToString([]byte("\x00sender@example.com\x00a"))
out := f.failure(&textproto.Error{Code: 535, Msg: "Rejected AUTH " + auth}, "id", model.Email{})
if out.SMTPStatus != 535 || strings.Contains(out.ProviderMessage, auth) || !strings.Contains(out.ProviderMessage, "AUTH [redacted]") {
t.Fatalf("encoded SMTP credential was not fully redacted: %q", out.ProviderMessage)
}
}