// Package mailsmoke_test exercises the real SMTP adapter of Mail // Service against a real SMTP receiver running in a testcontainer. // The suite is the small dedicated smoke suite called out in // `TESTING.md §4` ("Add only a small dedicated smoke suite for the // real mail adapter"). // // The boundary contract under test is: a delivery accepted on Mail's // internal HTTP surface in `smtp` mode is actually transmitted over // SMTP to the configured upstream and is observable on the // receiver's inspection API. No other Galaxy service is booted; the // test is intentionally narrow. package mailsmoke_test import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "errors" "fmt" "io" "math/big" "net" "net/http" "path/filepath" "runtime" "strconv" "sync/atomic" "testing" "time" "galaxy/integration/internal/harness" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" testcontainers "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) const ( mailpitImage = "axllent/mailpit:latest" mailpitSMTPPort = "1025/tcp" mailpitAPIPort = "8025/tcp" mailDeliveryPath = "/api/v1/internal/deliveries" commandSource = "mailsmoke" commandTemplate = "auth.login_code" smokeRecipient = "smoke-recipient@example.com" smokeFromEmail = "noreply@galaxy.example.com" ) var smokeSeq atomic.Int64 // TestMailServiceDeliversToRealSMTPProvider drives Mail Service in // `smtp` mode at a real Mailpit testcontainer. The service must // transmit the configured payload over SMTP and the receiver must // register it as a stored message visible on its HTTP inspection API. func TestMailServiceDeliversToRealSMTPProvider(t *testing.T) { mailpit := startMailpitContainer(t) mailService := startMailServiceWithSMTP(t, mailpit.SMTPEndpoint()) suffix := strconv.FormatInt(smokeSeq.Add(1), 10) idempotencyKey := "mailsmoke:" + suffix uniqueRecipient := "smoke-" + suffix + "-" + smokeRecipient // Mail Service has a synchronous trusted REST surface for the // auth login-code path (`/api/v1/internal/login-code-deliveries`). // It accepts the request, renders the template, and drives the // configured SMTP provider — exactly what the smoke suite needs // to verify against the real Mailpit container. loginCodeBody := map[string]any{ "email": uniqueRecipient, "code": "123456", "locale": "en", } bodyBytes, err := json.Marshal(loginCodeBody) require.NoError(t, err) req, err := http.NewRequest(http.MethodPost, mailService.BaseURL+"/api/v1/internal/login-code-deliveries", bytes.NewReader(bodyBytes), ) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", idempotencyKey) resp := doRequest(t, req) require.Equalf(t, http.StatusOK, resp.StatusCode, "submit login-code delivery: %s", resp.Body, ) // Mailpit exposes received messages at /api/v1/messages with a // JSON envelope containing `messages_count` plus per-message // items. Wait until our envelope shows up. waitForMailpitMessage(t, mailpit.APIBaseURL(), uniqueRecipient, 30*time.Second) } // --- mailpit container --- type mailpitContainer struct { container testcontainers.Container smtpHost string smtpPort string apiHost string apiPort string } func (m *mailpitContainer) SMTPEndpoint() string { return m.smtpHost + ":" + m.smtpPort } func (m *mailpitContainer) APIBaseURL() string { return "http://" + m.apiHost + ":" + m.apiPort } func startMailpitContainer(t *testing.T) *mailpitContainer { t.Helper() // Mail Service hardcodes `gomail.TLSMandatory`; the smoke suite // must give Mailpit a usable cert+key so STARTTLS succeeds even // against a self-signed server. The cert is short-lived and is // regenerated per test run. certPEM, keyPEM := generateSelfSignedCert(t, "mailpit-smoke") ctx := context.Background() req := testcontainers.ContainerRequest{ Image: mailpitImage, ExposedPorts: []string{ mailpitSMTPPort, mailpitAPIPort, }, Env: map[string]string{ "MP_SMTP_TLS_CERT": "/etc/mailpit/cert.pem", "MP_SMTP_TLS_KEY": "/etc/mailpit/key.pem", }, Files: []testcontainers.ContainerFile{ { Reader: bytes.NewReader(certPEM), ContainerFilePath: "/etc/mailpit/cert.pem", FileMode: 0o644, }, { Reader: bytes.NewReader(keyPEM), ContainerFilePath: "/etc/mailpit/key.pem", FileMode: 0o600, }, }, WaitingFor: wait.ForLog("accessible via"). WithStartupTimeout(30 * time.Second), } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) require.NoError(t, err) t.Cleanup(func() { if err := testcontainers.TerminateContainer(container); err != nil { t.Errorf("terminate mailpit container: %v", err) } }) smtpHost, err := container.Host(ctx) require.NoError(t, err) smtpPort, err := container.MappedPort(ctx, mailpitSMTPPort) require.NoError(t, err) apiPort, err := container.MappedPort(ctx, mailpitAPIPort) require.NoError(t, err) return &mailpitContainer{ container: container, smtpHost: smtpHost, smtpPort: smtpPort.Port(), apiHost: smtpHost, apiPort: apiPort.Port(), } } func waitForMailpitMessage(t *testing.T, apiBaseURL, recipient string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { req, err := http.NewRequest(http.MethodGet, apiBaseURL+"/api/v1/messages", nil) require.NoError(t, err) resp := doRequest(t, req) if resp.StatusCode == http.StatusOK { var body struct { Messages []struct { To []struct { Address string `json:"Address"` } `json:"To"` Subject string `json:"Subject"` } `json:"messages"` } if json.Unmarshal([]byte(resp.Body), &body) == nil { for _, m := range body.Messages { for _, addr := range m.To { if addr.Address == recipient { return } } } } } time.Sleep(100 * time.Millisecond) } t.Fatalf("mailpit did not register a message for %s within %s", recipient, timeout) } // --- mail service in real-SMTP mode --- type mailService struct { BaseURL string } func startMailServiceWithSMTP(t *testing.T, smtpAddr string) mailService { t.Helper() redisRuntime := harness.StartRedisContainer(t) mailInternalAddr := harness.FreeTCPAddress(t) mailBinary := harness.BuildBinary(t, "mail", "./mail/cmd/mail") mailEnv := harness.StartMailServicePersistence(t, redisRuntime.Addr).Env mailEnv["MAIL_LOG_LEVEL"] = "info" mailEnv["MAIL_INTERNAL_HTTP_ADDR"] = mailInternalAddr mailEnv["MAIL_TEMPLATE_DIR"] = mailTemplateDir(t) mailEnv["MAIL_SMTP_MODE"] = "smtp" mailEnv["MAIL_SMTP_ADDR"] = smtpAddr mailEnv["MAIL_SMTP_FROM_EMAIL"] = smokeFromEmail mailEnv["MAIL_SMTP_FROM_NAME"] = "Galaxy Mail Smoke" mailEnv["MAIL_SMTP_TIMEOUT"] = "10s" mailEnv["MAIL_SMTP_INSECURE_SKIP_VERIFY"] = "true" mailEnv["MAIL_STREAM_BLOCK_TIMEOUT"] = "100ms" mailEnv["MAIL_OPERATOR_REQUEST_TIMEOUT"] = "5s" mailEnv["MAIL_SHUTDOWN_TIMEOUT"] = "2s" mailEnv["OTEL_TRACES_EXPORTER"] = "none" mailEnv["OTEL_METRICS_EXPORTER"] = "none" mailProcess := harness.StartProcess(t, "mail", mailBinary, mailEnv) waitForMailReady(t, mailProcess, "http://"+mailInternalAddr) return mailService{BaseURL: "http://" + mailInternalAddr} } // --- shared helpers --- func waitForMailReady(t *testing.T, process *harness.Process, baseURL string) { t.Helper() client := &http.Client{Timeout: 250 * time.Millisecond} t.Cleanup(client.CloseIdleConnections) deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { req, err := http.NewRequest(http.MethodGet, baseURL+mailDeliveryPath, nil) require.NoError(t, err) response, err := client.Do(req) if err == nil { _, _ = io.Copy(io.Discard, response.Body) response.Body.Close() if response.StatusCode == http.StatusOK { return } } time.Sleep(25 * time.Millisecond) } t.Fatalf("wait for mail readiness: timeout\n%s", process.Logs()) } type httpResponse struct { StatusCode int Body string Header http.Header } func postJSON(t *testing.T, url string, body any) httpResponse { t.Helper() payload, err := json.Marshal(body) require.NoError(t, err) req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") return doRequest(t, req) } func doRequest(t *testing.T, request *http.Request) httpResponse { t.Helper() client := &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{DisableKeepAlives: true}, } t.Cleanup(client.CloseIdleConnections) response, err := client.Do(request) require.NoError(t, err) defer response.Body.Close() payload, err := io.ReadAll(response.Body) require.NoError(t, err) return httpResponse{ StatusCode: response.StatusCode, Body: string(payload), Header: response.Header.Clone(), } } // generateSelfSignedCert produces a short-lived RSA cert + key for the // Mailpit container so STARTTLS succeeds against // `MAIL_SMTP_INSECURE_SKIP_VERIFY=true` clients. func generateSelfSignedCert(t *testing.T, commonName string) ([]byte, []byte) { t.Helper() priv, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) require.NoError(t, err) template := x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: commonName}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, DNSNames: []string{"localhost", commonName}, } certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) require.NoError(t, err) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) keyPEM := pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv), }) return certPEM, keyPEM } func mailTemplateDir(t *testing.T) string { t.Helper() return filepath.Join(repositoryRoot(t), "mail", "templates") } func repositoryRoot(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("resolve repository root: runtime caller is unavailable") } return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) } // silence unused-import noise for symbols touched only via reflection / // conditional compilation. var _ = fmt.Sprintf var _ = errors.New var _ = assert.Equal