From b36eb72c23e0c8978980a94848dcdcb6bde50458 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 17 Oct 2026 20:51:28 +0200 Subject: [PATCH] Use Sendico SMTP mailbox for local notifications (#57) --- backend/notifications/PROVENANCE.md | 6 + backend/notifications/README.md | 33 +++- backend/notifications/config.example.yml | 2 +- backend/notifications/email/driver.go | 5 + backend/notifications/email/failure.go | 6 +- backend/notifications/email/mime.go | 49 +++++ backend/notifications/email/smtp.go | 122 +++++++++++++ backend/notifications/email/smtp_test.go | 223 +++++++++++++++++++++++ backend/notifications/service/worker.go | 3 + docs/local-development.md | 43 +++-- docs/signup-facts.md | 33 ++++ infra/images/backend.Dockerfile | 2 +- infra/local/notifications/runtime.yml | 10 +- infra/local/scripts/bootstrap.sh | 14 +- infra/local/scripts/common.sh | 1 + infra/local/scripts/test.sh | 4 + infra/local/scripts/verify.sh | 4 +- infra/local/vault/notifications.hcl | 4 +- 18 files changed, 528 insertions(+), 36 deletions(-) create mode 100644 backend/notifications/email/mime.go create mode 100644 backend/notifications/email/smtp.go create mode 100644 backend/notifications/email/smtp_test.go diff --git a/backend/notifications/PROVENANCE.md b/backend/notifications/PROVENANCE.md index b8f4902..1a563a7 100644 --- a/backend/notifications/PROVENANCE.md +++ b/backend/notifications/PROVENANCE.md @@ -11,6 +11,12 @@ classifies delivery outcomes and never logs provider response bodies. Sendico's builders, dynamic-template dependency, notification events and unused producer 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 `api/pkg/messaging`: `config.go`, `capability/errors.go`, `reliable/transport.go`, `reliable/durable_settings.go`, `reliable/durable_settings_behavior.go`, diff --git a/backend/notifications/README.md b/backend/notifications/README.md index fb0bb56..1b56290 100644 --- a/backend/notifications/README.md +++ b/backend/notifications/README.md @@ -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 transport, adapted from Sendico. A durable NATS worker claims the notification 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 uses adapted Sendico SDK construction; Resend uses its email HTTP API. Both support 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 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 is 24 hours. SendGrid has no equivalent guarantee; exactly-once external email 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 `driver_changed`. Drain queued jobs before changing sender credentials/config. - 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 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. +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. ## Validate and distribute diff --git a/backend/notifications/config.example.yml b/backend/notifications/config.example.yml index ff763e2..a1d7194 100644 --- a/backend/notifications/config.example.yml +++ b/backend/notifications/config.example.yml @@ -13,7 +13,7 @@ messaging: reconnect_wait: 2 templates: "templates.example.json" email: - driver: resend # sendgrid or resend + driver: resend # sendgrid or resend; see README for authenticated SMTP settings settings: api_key_env: "RESEND_API_KEY" # SENDGRID_API_KEY when driver is sendgrid sender_address: "notifications@example.com" diff --git a/backend/notifications/email/driver.go b/backend/notifications/email/driver.go index 1686df7..aeb832b 100644 --- a/backend/notifications/email/driver.go +++ b/backend/notifications/email/driver.go @@ -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 { diff --git a/backend/notifications/email/failure.go b/backend/notifications/email/failure.go index 2df22cd..7b1430e 100644 --- a/backend/notifications/email/failure.go +++ b/backend/notifications/email/failure.go @@ -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]") diff --git a/backend/notifications/email/mime.go b/backend/notifications/email/mime.go new file mode 100644 index 0000000..021078e --- /dev/null +++ b/backend/notifications/email/mime.go @@ -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 +} diff --git a/backend/notifications/email/smtp.go b/backend/notifications/email/smtp.go new file mode 100644 index 0000000..51bcb2e --- /dev/null +++ b/backend/notifications/email/smtp.go @@ -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 +} diff --git a/backend/notifications/email/smtp_test.go b/backend/notifications/email/smtp_test.go new file mode 100644 index 0000000..a454bf2 --- /dev/null +++ b/backend/notifications/email/smtp_test.go @@ -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: "

Confirm code 123456

"} + 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\" " || !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) + } +} diff --git a/backend/notifications/service/worker.go b/backend/notifications/service/worker.go index cfb68a6..1fa96f4 100644 --- a/backend/notifications/service/worker.go +++ b/backend/notifications/service/worker.go @@ -68,6 +68,9 @@ func (s *Service) Dispatch(ctx context.Context, id string) (bool, error) { if outcome.HTTPStatus != 0 { 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 != "" { fields = append(fields, zap.String("provider_error", outcome.ProviderError)) } diff --git a/docs/local-development.md b/docs/local-development.md index eb57cb4..5cef411 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -30,7 +30,7 @@ NATS, notifications, BFF and Caddy serving the frontend. On first startup, bootstrap initializes/unseals persistent Vault, provisions AppRoles and secrets, initializes Mongo replica set `rs0`, configures Vault's Mongo 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 exported, `make up` prints a Sendico-style completion message with the frontend, @@ -56,7 +56,7 @@ kv/ ├── services/ │ ├── bff/jwt # jwt │ └── notifications/ -│ └── email/resend # api_key only +│ └── email/smtp # password only └── infrastructure/ ├── nats/auth # token; read by NATS and notifications └── 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 on the trusted private Docker network without caller authentication. -## Resend +## Local SMTP mail Open [Vault](http://127.0.0.1:18200) and edit -`kv/services/notifications/email/resend`: +`kv/services/notifications/email/smtp`: | 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`: ```yaml email: - driver: resend + driver: smtp settings: - api_key_env: EMAIL_API_KEY - sender_address: "your-verified-sender@example.com" + host: mail.sendico.io + port: 465 + username: no-reply@sendico.io + password_env: SMTP_PASSWORD + network_timeout: 10 + sender_address: no-reply@sendico.io sender_name: "SXCHNGE" ``` -Set your verified address in that file and rebuild. It is intentionally empty by -default, so notifications cannot start with an unconfigured sender. Vault Agent -injects only the API key; it does not generate email configuration. +The local stack uses Sendico's existing `no-reply@sendico.io` mailbox with +authenticated implicit TLS on port 465 and certificate verification enabled. +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`. Product containers cannot access it. Alternatively use the hidden terminal prompt: @@ -106,12 +112,15 @@ make email make build && make up ``` -No placeholder key or delivery fallback is installed. Startup validates presence; -it does not send an email or validate domain ownership with Resend. Updating the -API key causes Agent to restart notifications. Driver/sender changes require an -image rebuild. Existing API keys survive bootstrap. Migration removes legacy -driver/sender fields from the current Vault secret with a version-checked write; -the preceding version is preserved in Vault history and the private archive. +No placeholder password or delivery fallback is installed. Startup validates +configuration and secret presence; the SMTP connection is opened on demand. +Updating the password causes Agent to restart notifications. Driver/sender +changes require an image rebuild. Old Resend secrets remain in Vault history +but are not used or readable by the notifications AppRole. HTTP providers remain +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 diff --git a/docs/signup-facts.md b/docs/signup-facts.md index a0636ce..5e95fed 100644 --- a/docs/signup-facts.md +++ b/docs/signup-facts.md @@ -94,6 +94,39 @@ gRPC failure response. No security scan or real customer email retry was run. ## 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 and activation token atomically, then registers an independent individual through Sendico's public HTTP API and submits the sign-up assertions. It persists the diff --git a/infra/images/backend.Dockerfile b/infra/images/backend.Dockerfile index 836195b..01e94d0 100644 --- a/infra/images/backend.Dockerfile +++ b/infra/images/backend.Dockerfile @@ -5,7 +5,7 @@ RUN apk add --no-cache protobuf protobuf-dev make WORKDIR /src COPY Makefile ./ 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 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 . diff --git a/infra/local/notifications/runtime.yml b/infra/local/notifications/runtime.yml index 0e45830..e550c14 100644 --- a/infra/local/notifications/runtime.yml +++ b/infra/local/notifications/runtime.yml @@ -18,10 +18,14 @@ messaging: reconnect_wait: 2 templates: "/etc/sxchnge/templates.json" email: - driver: resend + driver: smtp settings: - api_key_env: EMAIL_API_KEY - sender_address: "" # Set your verified Resend sender here, then run make build. + host: mail.sendico.io + port: 465 + username: no-reply@sendico.io + password_env: SMTP_PASSWORD + network_timeout: 10 + sender_address: no-reply@sendico.io sender_name: "SXCHNGE" # Use replicas: 3 and environment: production for a three-node NATS cluster. # mode: ops validates resources without modifying them; app provisions them. diff --git a/infra/local/scripts/bootstrap.sh b/infra/local/scripts/bootstrap.sh index 9e07d40..357f8c5 100644 --- a/infra/local/scripts/bootstrap.sh +++ b/infra/local/scripts/bootstrap.sh @@ -61,7 +61,7 @@ provision_agents() { mkdir -p "$directory"; chmod 755 "$directory" case "$service" in 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;; sendico) paths=services/bff/sendico;; esac @@ -105,13 +105,12 @@ up() { random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path" fi done - printf '{"api_key":""}' | ensure services/notifications/email/resend provision_mongo provision_agents compose up -d --wait --wait-timeout 120 nats retire_secrets 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 export_ca cat <<'MESSAGE' @@ -134,7 +133,7 @@ MESSAGE } email() { existing - printf 'Resend API key (hidden): ' >&2 + printf 'SMTP mailbox password (hidden): ' >&2 tty_state=$(stty -g) trap 'stty "$tty_state"; cleanup' EXIT stty -echo @@ -143,7 +142,8 @@ email() { printf '\n' >&2 [ -n "$key" ] || fail 'A key is required' printf '%s' "$key" > "$WORK/email-key" - normalize_email_secret - jq -n --rawfile key "$WORK/email-key" '{api_key:$key}' | put services/notifications/email/resend - echo 'API key saved in Vault. Set the verified sender in infra/local/notifications/runtime.yml, then make build && make up.' + jq -n --rawfile password "$WORK/email-key" '{password:$password}' > "$WORK/smtp-secret" + smtp_valid < "$WORK/smtp-secret" || fail 'Invalid SMTP mailbox password' + put services/notifications/email/smtp < "$WORK/smtp-secret" + echo 'SMTP password saved in Vault. The notifications Agent reloads it automatically.' } diff --git a/infra/local/scripts/common.sh b/infra/local/scripts/common.sh index c719756..6f98c95 100644 --- a/infra/local/scripts/common.sh +++ b/infra/local/scripts/common.sh @@ -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"; } 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; } +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() { compose exec -T frontend cat /data/caddy/pki/authorities/local/root.crt | write "$STATE/ca.pem" 644 } diff --git a/infra/local/scripts/test.sh b/infra/local/scripts/test.sh index 0601b48..424191d 100644 --- a/infra/local/scripts/test.sh +++ b/infra/local/scripts/test.sh @@ -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' 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 '{"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. vault() { printf '{"initialized":true}'; } if (initialize) > "$WORK/initialize-error" 2>&1; then fail 'Missing local keys accepted'; fi diff --git a/infra/local/scripts/verify.sh b/infra/local/scripts/verify.sh index ebb43f0..34f5c54 100644 --- a/infra/local/scripts/verify.sh +++ b/infra/local/scripts/verify.sh @@ -61,11 +61,11 @@ layout() { 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' 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 [ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old" 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" done echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent' diff --git a/infra/local/vault/notifications.hcl b/infra/local/vault/notifications.hcl index 7e332ff..fd4c967 100644 --- a/infra/local/vault/notifications.hcl +++ b/infra/local/vault/notifications.hcl @@ -22,8 +22,8 @@ env_template "SXCHNGE_NATS_URL" { -env_template "EMAIL_API_KEY" { - contents = "{{ with secret \"kv/data/services/notifications/email/resend\" }}{{ .Data.data.api_key }}{{ end }}" +env_template "SMTP_PASSWORD" { + contents = "{{ with secret \"kv/data/services/notifications/email/smtp\" }}{{ .Data.data.password }}{{ end }}" error_on_missing_key = true }