Use Sendico SMTP mailbox for local notifications (#57)
This commit is contained in:
1 parent
3e4e496037
commit
b36eb72c23
18 files changed
+528
-36
No files matched your search
@@ -11,6 +11,12 @@ classifies delivery outcomes and never logs provider response bodies. Sendico's
|
|||||||
builders, dynamic-template dependency, notification events and unused producer
|
builders, dynamic-template dependency, notification events and unused producer
|
||||||
were removed. Resend is a new driver using the same contract.
|
were removed. Resend is a new driver using the same contract.
|
||||||
|
|
||||||
|
SMTP is an owned adapter using Go's standard `net/smtp`, verified `crypto/tls`
|
||||||
|
connections and standard MIME encoders. Its implicit-TLS connection configuration
|
||||||
|
matches Sendico's existing mail client, without importing its modules or copying
|
||||||
|
its keepalive/reconnect behavior. Local mailbox credentials are provisioned into
|
||||||
|
SXCHNGE Vault explicitly; the application does not read Sendico files or secrets.
|
||||||
|
|
||||||
Shared messaging was selected from that same Sendico revision's
|
Shared messaging was selected from that same Sendico revision's
|
||||||
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
|
`api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`,
|
||||||
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
|
`reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`,
|
||||||
|
|||||||
@@ -12,12 +12,35 @@ pending-work state atomically in one document through the shared DB wrapper.
|
|||||||
The outbox dispatcher publishes opaque IDs using shared messaging's durable
|
The outbox dispatcher publishes opaque IDs using shared messaging's durable
|
||||||
transport, adapted from Sendico. A durable NATS worker claims the notification
|
transport, adapted from Sendico. A durable NATS worker claims the notification
|
||||||
with an atomic conditional patch and saves the outcome before acknowledging.
|
with an atomic conditional patch and saves the outcome before acknowledging.
|
||||||
`DriverConfig` selects the email driver: `sendgrid` or `resend`.
|
`DriverConfig` selects the email driver: `sendgrid`, `resend` or `smtp`.
|
||||||
|
|
||||||
Email adapters only map rendered email models to provider wire payloads. SendGrid
|
Email adapters only map rendered email models to provider wire payloads. SendGrid
|
||||||
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
|
uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support
|
||||||
subject, text and HTML, bounded timeouts and safe error classification.
|
subject, text and HTML, bounded timeouts and safe error classification.
|
||||||
|
|
||||||
|
The owned SMTP adapter uses authenticated implicit TLS (normally port 465),
|
||||||
|
verified server certificates and context-bounded connections. It sends MIME
|
||||||
|
multipart/alternative with quoted-printable UTF-8 text/HTML and encoded subjects.
|
||||||
|
It uses Go's standard SMTP client and does not import Sendico modules or share a
|
||||||
|
mutable connection between workers. Configure it with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
email:
|
||||||
|
driver: smtp
|
||||||
|
settings:
|
||||||
|
host: mail.example.com
|
||||||
|
port: 465
|
||||||
|
username: notifications@example.com
|
||||||
|
password_env: SMTP_PASSWORD
|
||||||
|
sender_address: notifications@example.com
|
||||||
|
sender_name: SXCHNGE
|
||||||
|
network_timeout: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
`network_timeout` must be 1–20 seconds; the worker's expiry/shutdown context also
|
||||||
|
bounds every operation. Only the password belongs in Vault. There is no plaintext
|
||||||
|
fallback or certificate-verification bypass.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
|
||||||
@@ -75,6 +98,12 @@ remaining status and request fingerprints expire through a seven-day TTL.
|
|||||||
- Resend receives the notification ID as its provider idempotency key. Its window
|
- Resend receives the notification ID as its provider idempotency key. Its window
|
||||||
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
|
is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email
|
||||||
delivery is not promised.
|
delivery is not promised.
|
||||||
|
- SMTP acceptance requires the final DATA 250 reply. A later QUIT failure cannot
|
||||||
|
undo acceptance. Explicit SMTP 4xx replies become retryable
|
||||||
|
`provider_temporarily_unavailable`; 5xx replies are terminal (authentication
|
||||||
|
failures retain `provider_authentication_failed`). Transport/TLS errors and a
|
||||||
|
lost final DATA reply become UNKNOWN. No immediate reconnect/resend occurs.
|
||||||
|
A stable Message-ID is included for correlation, not an exactly-once promise.
|
||||||
- Changing the configured driver fails queued jobs pinned to the old driver with
|
- Changing the configured driver fails queued jobs pinned to the old driver with
|
||||||
`driver_changed`. Drain queued jobs before changing sender credentials/config.
|
`driver_changed`. Drain queued jobs before changing sender credentials/config.
|
||||||
- Callers authorize recipients and preferences. No direct customer-database access,
|
- Callers authorize recipients and preferences. No direct customer-database access,
|
||||||
@@ -102,6 +131,8 @@ an instruction. Use `provider_status`, `provider_error` and `provider_message`
|
|||||||
together: the generic `provider_authentication_failed` category combines HTTP
|
together: the generic `provider_authentication_failed` category combines HTTP
|
||||||
401 and 403, including sender/domain authorization failures. A sending-only
|
401 and 403, including sender/domain authorization failures. A sending-only
|
||||||
Resend key is valid for sending, but cannot read the provider's log API.
|
Resend key is valid for sending, but cannot read the provider's log API.
|
||||||
|
SMTP outcomes use `provider_smtp_status` for protocol reply codes and the same
|
||||||
|
sanitized provider error/message fields, including password/auth-payload redaction.
|
||||||
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
|
Monitor pending age, UNKNOWN/FAILED states and repeated outbox/worker warnings.
|
||||||
|
|
||||||
## Validate and distribute
|
## Validate and distribute
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ messaging:
|
|||||||
reconnect_wait: 2
|
reconnect_wait: 2
|
||||||
templates: "templates.example.json"
|
templates: "templates.example.json"
|
||||||
email:
|
email:
|
||||||
driver: resend # sendgrid or resend
|
driver: resend # sendgrid or resend; see README for authenticated SMTP settings
|
||||||
settings:
|
settings:
|
||||||
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
|
api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid
|
||||||
sender_address: "notifications@example.com"
|
sender_address: "notifications@example.com"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Driver string
|
|||||||
const (
|
const (
|
||||||
SendGrid Driver = "sendgrid"
|
SendGrid Driver = "sendgrid"
|
||||||
Resend Driver = "resend"
|
Resend Driver = "resend"
|
||||||
|
SMTP Driver = "smtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config = sharedmodel.DriverConfig[Driver]
|
type Config = sharedmodel.DriverConfig[Driver]
|
||||||
@@ -35,6 +36,7 @@ type Outcome struct {
|
|||||||
RetryAfter time.Duration
|
RetryAfter time.Duration
|
||||||
// Provider diagnostics are sanitized operational data, not public failures.
|
// Provider diagnostics are sanitized operational data, not public failures.
|
||||||
HTTPStatus int
|
HTTPStatus int
|
||||||
|
SMTPStatus int
|
||||||
ProviderError string
|
ProviderError string
|
||||||
ProviderMessage string
|
ProviderMessage string
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,9 @@ type client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(config Config) (Sender, error) {
|
func New(config Config) (Sender, error) {
|
||||||
|
if config.Driver == SMTP {
|
||||||
|
return newSMTP(config)
|
||||||
|
}
|
||||||
var s Settings
|
var s Settings
|
||||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ func providerFailure(body, request []byte, secrets ...string) (string, string) {
|
|||||||
secrets = append(secrets, numbers.FindAllString(value, -1)...)
|
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)
|
name := safeErrorText(dto.Name, secrets)
|
||||||
if !errorName.MatchString(name) {
|
if !errorName.MatchString(name) {
|
||||||
name = ""
|
name = ""
|
||||||
@@ -76,11 +75,14 @@ func requestStrings(value any) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func safeErrorText(text string, secrets []string) 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 {
|
for _, secret := range secrets {
|
||||||
if secret != "" {
|
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 = addresses.ReplaceAllString(text, "[redacted-address]")
|
||||||
text = urls.ReplaceAllString(text, "[redacted-url]")
|
text = urls.ReplaceAllString(text, "[redacted-url]")
|
||||||
text = tokens.ReplaceAllString(text, "[redacted-token]")
|
text = tokens.ReplaceAllString(text, "[redacted-token]")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,9 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) {
|
|||||||
if outcome.HTTPStatus != 0 {
|
if outcome.HTTPStatus != 0 {
|
||||||
fields = append(fields, zap.Int("provider_status", outcome.HTTPStatus))
|
fields = append(fields, zap.Int("provider_status", outcome.HTTPStatus))
|
||||||
}
|
}
|
||||||
|
if outcome.SMTPStatus != 0 {
|
||||||
|
fields = append(fields, zap.Int("provider_smtp_status", outcome.SMTPStatus))
|
||||||
|
}
|
||||||
if outcome.ProviderError != "" {
|
if outcome.ProviderError != "" {
|
||||||
fields = append(fields, zap.String("provider_error", outcome.ProviderError))
|
fields = append(fields, zap.String("provider_error", outcome.ProviderError))
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-17
@@ -30,7 +30,7 @@ NATS, notifications, BFF and Caddy serving the frontend.
|
|||||||
On first startup, bootstrap initializes/unseals persistent Vault, provisions
|
On first startup, bootstrap initializes/unseals persistent Vault, provisions
|
||||||
AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo
|
AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo
|
||||||
plugin, and starts authenticated NATS JetStream. Product startup fails explicitly
|
plugin, and starts authenticated NATS JetStream. Product startup fails explicitly
|
||||||
until Resend credentials exist. Infrastructure remains available for provisioning.
|
until the SMTP mailbox password exists. Infrastructure remains available for provisioning.
|
||||||
|
|
||||||
After all services pass their startup health checks and the public Caddy CA is
|
After all services pass their startup health checks and the public Caddy CA is
|
||||||
exported, `make up` prints a Sendico-style completion message with the frontend,
|
exported, `make up` prints a Sendico-style completion message with the frontend,
|
||||||
@@ -56,7 +56,7 @@ kv/
|
|||||||
├── services/
|
├── services/
|
||||||
│ ├── bff/jwt # jwt
|
│ ├── bff/jwt # jwt
|
||||||
│ └── notifications/
|
│ └── notifications/
|
||||||
│ └── email/resend # api_key only
|
│ └── email/smtp # password only
|
||||||
└── infrastructure/
|
└── infrastructure/
|
||||||
├── nats/auth # token; read by NATS and notifications
|
├── nats/auth # token; read by NATS and notifications
|
||||||
└── mongo/bootstrap # local administrator and replica-set bootstrap material
|
└── mongo/bootstrap # local administrator and replica-set bootstrap material
|
||||||
@@ -74,29 +74,35 @@ There are no frontend certificates, notification certificates or generic `tls`
|
|||||||
entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext
|
entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext
|
||||||
on the trusted private Docker network without caller authentication.
|
on the trusted private Docker network without caller authentication.
|
||||||
|
|
||||||
## Resend
|
## Local SMTP mail
|
||||||
|
|
||||||
Open [Vault](http://127.0.0.1:18200) and edit
|
Open [Vault](http://127.0.0.1:18200) and edit
|
||||||
`kv/services/notifications/email/resend`:
|
`kv/services/notifications/email/smtp`:
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `api_key` | Your Resend API key |
|
| `password` | Password for the configured SMTP mailbox |
|
||||||
|
|
||||||
The driver and sender settings belong in `infra/local/notifications/runtime.yml`:
|
The driver and sender settings belong in `infra/local/notifications/runtime.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
email:
|
email:
|
||||||
driver: resend
|
driver: smtp
|
||||||
settings:
|
settings:
|
||||||
api_key_env: EMAIL_API_KEY
|
host: mail.sendico.io
|
||||||
sender_address: "your-verified-sender@example.com"
|
port: 465
|
||||||
|
username: no-reply@sendico.io
|
||||||
|
password_env: SMTP_PASSWORD
|
||||||
|
network_timeout: 10
|
||||||
|
sender_address: no-reply@sendico.io
|
||||||
sender_name: "SXCHNGE"
|
sender_name: "SXCHNGE"
|
||||||
```
|
```
|
||||||
|
|
||||||
Set your verified address in that file and rebuild. It is intentionally empty by
|
The local stack uses Sendico's existing `no-reply@sendico.io` mailbox with
|
||||||
default, so notifications cannot start with an unconfigured sender. Vault Agent
|
authenticated implicit TLS on port 465 and certificate verification enabled.
|
||||||
injects only the API key; it does not generate email configuration.
|
Vault Agent injects only the password; host, username and sender are non-secret
|
||||||
|
runtime settings. Credentials are provisioned into SXCHNGE's own Vault; the
|
||||||
|
notification service does not read Sendico configuration or secrets at runtime.
|
||||||
|
|
||||||
Local administrator login material is in private `.local/dev/admin/init.json`.
|
Local administrator login material is in private `.local/dev/admin/init.json`.
|
||||||
Product containers cannot access it. Alternatively use the hidden terminal prompt:
|
Product containers cannot access it. Alternatively use the hidden terminal prompt:
|
||||||
@@ -106,12 +112,15 @@ make email
|
|||||||
make build && make up
|
make build && make up
|
||||||
```
|
```
|
||||||
|
|
||||||
No placeholder key or delivery fallback is installed. Startup validates presence;
|
No placeholder password or delivery fallback is installed. Startup validates
|
||||||
it does not send an email or validate domain ownership with Resend. Updating the
|
configuration and secret presence; the SMTP connection is opened on demand.
|
||||||
API key causes Agent to restart notifications. Driver/sender changes require an
|
Updating the password causes Agent to restart notifications. Driver/sender
|
||||||
image rebuild. Existing API keys survive bootstrap. Migration removes legacy
|
changes require an image rebuild. Old Resend secrets remain in Vault history
|
||||||
driver/sender fields from the current Vault secret with a version-checked write;
|
but are not used or readable by the notifications AppRole. HTTP providers remain
|
||||||
the preceding version is preserved in Vault history and the private archive.
|
available for other deployments; switching one requires matching Agent/policy
|
||||||
|
and runtime configuration. SMTP has no provider idempotency guarantee: explicit
|
||||||
|
4xx rejections retry, 5xx rejections fail, and ambiguous transport outcomes require
|
||||||
|
operator reconciliation before another send.
|
||||||
|
|
||||||
## Browser HTTPS and internal connections
|
## Browser HTTPS and internal connections
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,39 @@ gRPC failure response. No security scan or real customer email retry was run.
|
|||||||
|
|
||||||
## Behavior and boundaries
|
## Behavior and boundaries
|
||||||
|
|
||||||
|
### Local mail client
|
||||||
|
|
||||||
|
The user subsequently selected Sendico's existing mailbox instead of Resend for
|
||||||
|
local notifications. The authoritative Sendico configuration resolves to
|
||||||
|
`no-reply@sendico.io`, `mail.sendico.io:465`, authenticated implicit TLS and a
|
||||||
|
10-second network timeout. SXCHNGE's owned `smtp` adapter consumes rendered email
|
||||||
|
models through the existing sender contract and queue; it does not import or
|
||||||
|
access Sendico business modules, databases or messaging. The password was copied
|
||||||
|
once, without printing it, from the running Sendico notification environment into
|
||||||
|
`services/notifications/email/smtp` in SXCHNGE Vault. Only `password` is stored
|
||||||
|
there; host, port, username, display name and sender remain in runtime YAML.
|
||||||
|
|
||||||
|
The SMTP adapter verifies certificates, authenticates before submission, sends
|
||||||
|
UTF-8 MIME alternatives, and requires the final DATA 250 reply for acceptance.
|
||||||
|
SMTP 4xx replies retry through the existing worker policy; 5xx replies fail;
|
||||||
|
ambiguous transport outcomes remain UNKNOWN and are not immediately resent.
|
||||||
|
Message-ID is correlation only, not a provider idempotency guarantee. Existing
|
||||||
|
failed Resend notifications are not replayed or rewritten. The unverified
|
||||||
|
`sxchnge.com` Resend domain is no longer the selected local delivery path.
|
||||||
|
|
||||||
|
Live verified-TLS SMTP authentication and NOOP succeeded with reply 250, without
|
||||||
|
submitting email. SMTP adapter race tests and backend lint pass. Local tooling
|
||||||
|
fixtures pass, including password-only Vault validation. Broader validation is
|
||||||
|
currently blocked by Docker: the service integration test timed out starting its
|
||||||
|
Testcontainers reaper; both `GOFLAGS=-p=2 make test` and its `-p=1` retry fail in
|
||||||
|
the existing credential-rotation setup while copying files into a disposable
|
||||||
|
MongoDB container (90-second context timeout). No checks were disabled. Fresh
|
||||||
|
stable image pins resolved on retry; two image builds hit a Go proxy TLS
|
||||||
|
handshake timeout during protobuf generator installation. The generator stage
|
||||||
|
now shares the existing BuildKit Go module/build caches, preserving its pinned
|
||||||
|
tool versions and generation command; the image rebuild is in progress.
|
||||||
|
|
||||||
|
|
||||||
With `api.sendico` configured, the product BFF creates the local pending customer
|
With `api.sendico` configured, the product BFF creates the local pending customer
|
||||||
and activation token atomically, then registers an independent individual through
|
and activation token atomically, then registers an independent individual through
|
||||||
Sendico's public HTTP API and submits the sign-up assertions. It persists the
|
Sendico's public HTTP API and submits the sign-up assertions. It persists the
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ RUN apk add --no-cache protobuf protobuf-dev make
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY Makefile ./
|
COPY Makefile ./
|
||||||
COPY backend ./backend
|
COPY backend ./backend
|
||||||
RUN make generate-notifications
|
RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod make generate-notifications
|
||||||
ARG MODULE
|
ARG MODULE
|
||||||
WORKDIR /src/backend/${MODULE}
|
WORKDIR /src/backend/${MODULE}
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 go build -trimpath -o /out/service .
|
RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 go build -trimpath -o /out/service .
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ messaging:
|
|||||||
reconnect_wait: 2
|
reconnect_wait: 2
|
||||||
templates: "/etc/sxchnge/templates.json"
|
templates: "/etc/sxchnge/templates.json"
|
||||||
email:
|
email:
|
||||||
driver: resend
|
driver: smtp
|
||||||
settings:
|
settings:
|
||||||
api_key_env: EMAIL_API_KEY
|
host: mail.sendico.io
|
||||||
sender_address: "" # Set your verified Resend sender here, then run make build.
|
port: 465
|
||||||
|
username: no-reply@sendico.io
|
||||||
|
password_env: SMTP_PASSWORD
|
||||||
|
network_timeout: 10
|
||||||
|
sender_address: no-reply@sendico.io
|
||||||
sender_name: "SXCHNGE"
|
sender_name: "SXCHNGE"
|
||||||
# Use replicas: 3 and environment: production for a three-node NATS cluster.
|
# Use replicas: 3 and environment: production for a three-node NATS cluster.
|
||||||
# mode: ops validates resources without modifying them; app provisions them.
|
# mode: ops validates resources without modifying them; app provisions them.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ provision_agents() {
|
|||||||
mkdir -p "$directory"; chmod 755 "$directory"
|
mkdir -p "$directory"; chmod 755 "$directory"
|
||||||
case "$service" in
|
case "$service" in
|
||||||
bff) paths='services/bff/jwt';;
|
bff) paths='services/bff/jwt';;
|
||||||
notifications) paths='infrastructure/nats/auth services/notifications/email/resend';;
|
notifications) paths='infrastructure/nats/auth services/notifications/email/smtp';;
|
||||||
nats) paths=infrastructure/nats/auth;;
|
nats) paths=infrastructure/nats/auth;;
|
||||||
sendico) paths=services/bff/sendico;;
|
sendico) paths=services/bff/sendico;;
|
||||||
esac
|
esac
|
||||||
@@ -105,13 +105,12 @@ up() {
|
|||||||
random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path"
|
random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
printf '{"api_key":""}' | ensure services/notifications/email/resend
|
|
||||||
provision_mongo
|
provision_mongo
|
||||||
provision_agents
|
provision_agents
|
||||||
compose up -d --wait --wait-timeout 120 nats
|
compose up -d --wait --wait-timeout 120 nats
|
||||||
retire_secrets
|
retire_secrets
|
||||||
normalize_email_secret
|
normalize_email_secret
|
||||||
kv services/notifications/email/resend | email_valid || fail 'Vault kv/services/notifications/email/resend needs api_key. Run make email; configure the verified sender in infra/local/notifications/runtime.yml, then make build && make up.'
|
kv services/notifications/email/smtp | smtp_valid || fail 'Vault kv/services/notifications/email/smtp needs the mailbox password. Run make email.'
|
||||||
compose up -d --wait --wait-timeout 180
|
compose up -d --wait --wait-timeout 180
|
||||||
export_ca
|
export_ca
|
||||||
cat <<'MESSAGE'
|
cat <<'MESSAGE'
|
||||||
@@ -134,7 +133,7 @@ MESSAGE
|
|||||||
}
|
}
|
||||||
email() {
|
email() {
|
||||||
existing
|
existing
|
||||||
printf 'Resend API key (hidden): ' >&2
|
printf 'SMTP mailbox password (hidden): ' >&2
|
||||||
tty_state=$(stty -g)
|
tty_state=$(stty -g)
|
||||||
trap 'stty "$tty_state"; cleanup' EXIT
|
trap 'stty "$tty_state"; cleanup' EXIT
|
||||||
stty -echo
|
stty -echo
|
||||||
@@ -143,7 +142,8 @@ email() {
|
|||||||
printf '\n' >&2
|
printf '\n' >&2
|
||||||
[ -n "$key" ] || fail 'A key is required'
|
[ -n "$key" ] || fail 'A key is required'
|
||||||
printf '%s' "$key" > "$WORK/email-key"
|
printf '%s' "$key" > "$WORK/email-key"
|
||||||
normalize_email_secret
|
jq -n --rawfile password "$WORK/email-key" '{password:$password}' > "$WORK/smtp-secret"
|
||||||
jq -n --rawfile key "$WORK/email-key" '{api_key:$key}' | put services/notifications/email/resend
|
smtp_valid < "$WORK/smtp-secret" || fail 'Invalid SMTP mailbox password'
|
||||||
echo 'API key saved in Vault. Set the verified sender in infra/local/notifications/runtime.yml, then make build && make up.'
|
put services/notifications/email/smtp < "$WORK/smtp-secret"
|
||||||
|
echo 'SMTP password saved in Vault. The notifications Agent reloads it automatically.'
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,7 @@ ensure() (
|
|||||||
mongo() { compose exec -T mongo mongosh --quiet --file /dev/stdin > "$WORK/mongo-result" 2> "$WORK/mongo-error" || return 1; cat "$WORK/mongo-result"; }
|
mongo() { compose exec -T mongo mongosh --quiet --file /dev/stdin > "$WORK/mongo-result" 2> "$WORK/mongo-error" || return 1; cat "$WORK/mongo-result"; }
|
||||||
admin_auth() { kv infrastructure/mongo/bootstrap | jq -r '"const admin=db.getSiblingDB(\"admin\"); admin.auth(\"local-admin\","+(.password|tojson)+");"'; }
|
admin_auth() { kv infrastructure/mongo/bootstrap | jq -r '"const admin=db.getSiblingDB(\"admin\"); admin.auth(\"local-admin\","+(.password|tojson)+");"'; }
|
||||||
email_valid() { jq -e '(.api_key|type=="string") and (.api_key|test("\\S"))' >/dev/null; }
|
email_valid() { jq -e '(.api_key|type=="string") and (.api_key|test("\\S"))' >/dev/null; }
|
||||||
|
smtp_valid() { jq -e 'keys==["password"] and (.password|type=="string" and length>0 and (explode|all(.>0 and .!=10 and .!=13)))' >/dev/null; }
|
||||||
export_ca() {
|
export_ca() {
|
||||||
compose exec -T frontend cat /data/caddy/pki/authorities/local/root.crt | write "$STATE/ca.pem" 644
|
compose exec -T frontend cat /data/caddy/pki/authorities/local/root.crt | write "$STATE/ca.pem" 644
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,10 @@ if images >/dev/null 2>&1; then fail 'Offline resolution succeeded'; fi
|
|||||||
[ "$(cat "$STATE/images.env")" = 'previous build' ] || fail 'Failed resolution replaced pins'
|
[ "$(cat "$STATE/images.env")" = 'previous build' ] || fail 'Failed resolution replaced pins'
|
||||||
if printf '{"api_key":"","sender_address":"notifications@localhost.test"}' | email_valid; then fail 'Empty email accepted'; fi
|
if printf '{"api_key":"","sender_address":"notifications@localhost.test"}' | email_valid; then fail 'Empty email accepted'; fi
|
||||||
printf '{"api_key":"fixture-only","sender_address":"sender@example.test"}' | email_valid
|
printf '{"api_key":"fixture-only","sender_address":"sender@example.test"}' | email_valid
|
||||||
|
printf '{"password":"fixture-only"}' | smtp_valid
|
||||||
|
for invalid in '{"password":""}' '{"password":"bad\nvalue"}' '{"password":"bad\u0000value"}' '{"password":"fixture","username":"not-a-secret"}'; do
|
||||||
|
if printf '%s' "$invalid" | smtp_valid; then fail 'Invalid SMTP secret accepted'; fi
|
||||||
|
done
|
||||||
# Initialized Vault without matching local keys must fail without initializing it again.
|
# Initialized Vault without matching local keys must fail without initializing it again.
|
||||||
vault() { printf '{"initialized":true}'; }
|
vault() { printf '{"initialized":true}'; }
|
||||||
if (initialize) > "$WORK/initialize-error" 2>&1; then fail 'Missing local keys accepted'; fi
|
if (initialize) > "$WORK/initialize-error" 2>&1; then fail 'Missing local keys accepted'; fi
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ layout() {
|
|||||||
if [ -f "$STATE/sendico/settings.env" ]; then
|
if [ -f "$STATE/sendico/settings.env" ]; then
|
||||||
kv services/bff/sendico | jq -e 'keys==["client_secret","login","password"]' >/dev/null || fail 'Sendico secret contains non-secret configuration'
|
kv services/bff/sendico | jq -e 'keys==["client_secret","login","password"]' >/dev/null || fail 'Sendico secret contains non-secret configuration'
|
||||||
fi
|
fi
|
||||||
kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault'
|
kv services/notifications/email/smtp | jq -e 'keys==["password"]' >/dev/null || fail 'SMTP secret contains non-secret configuration'
|
||||||
for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do
|
for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do
|
||||||
[ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
|
[ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
|
||||||
done
|
done
|
||||||
for path in services/bff/jwt services/notifications/email/resend infrastructure/nats/auth infrastructure/mongo/bootstrap; do
|
for path in services/bff/jwt services/notifications/email/smtp infrastructure/nats/auth infrastructure/mongo/bootstrap; do
|
||||||
vault "kv/metadata/$path" | jq -e '.data.current_version>0' >/dev/null || fail "Missing canonical KV entry: $path"
|
vault "kv/metadata/$path" | jq -e '.data.current_version>0' >/dev/null || fail "Missing canonical KV entry: $path"
|
||||||
done
|
done
|
||||||
echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent'
|
echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent'
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ env_template "SXCHNGE_NATS_URL" {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
env_template "EMAIL_API_KEY" {
|
env_template "SMTP_PASSWORD" {
|
||||||
contents = "{{ with secret \"kv/data/services/notifications/email/resend\" }}{{ .Data.data.api_key }}{{ end }}"
|
contents = "{{ with secret \"kv/data/services/notifications/email/smtp\" }}{{ .Data.data.password }}{{ end }}"
|
||||||
error_on_missing_key = true
|
error_on_missing_key = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user