feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
+394
View File
@@ -0,0 +1,394 @@
package authsessionmail_test
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"path/filepath"
"runtime"
"testing"
"time"
"galaxy/integration/internal/harness"
"github.com/stretchr/testify/require"
)
const (
authSendEmailCodePath = "/api/v1/public/auth/send-email-code"
mailDeliveriesPath = "/api/v1/internal/deliveries"
)
type authsessionMailHarness struct {
userStub *harness.UserStub
smtp *harness.SMTPCapture
authsessionPublicURL string
mailInternalURL string
authsessionProcess *harness.Process
mailProcess *harness.Process
}
type authsessionMailHarnessOptions struct {
mailSMTPMode string
}
type httpResponse struct {
StatusCode int
Body string
Header http.Header
}
type sendEmailCodeResponse struct {
ChallengeID string `json:"challenge_id"`
}
type mailDeliveryListResponse struct {
Items []mailDeliverySummary `json:"items"`
}
type mailDeliverySummary struct {
DeliveryID string `json:"delivery_id"`
Source string `json:"source"`
TemplateID string `json:"template_id"`
Locale string `json:"locale"`
To []string `json:"to"`
Status string `json:"status"`
}
type mailDeliveryDetailResponse struct {
DeliveryID string `json:"delivery_id"`
Source string `json:"source"`
TemplateID string `json:"template_id"`
Locale string `json:"locale"`
LocaleFallbackUsed bool `json:"locale_fallback_used"`
To []string `json:"to"`
IdempotencyKey string `json:"idempotency_key"`
Status string `json:"status"`
TemplateVariables map[string]any `json:"template_variables,omitempty"`
}
type mailDeliveryAttemptsResponse struct {
Items []mailAttemptResponse `json:"items"`
}
type mailAttemptResponse struct {
Status string `json:"status"`
}
func newAuthsessionMailHarness(t *testing.T, opts authsessionMailHarnessOptions) *authsessionMailHarness {
t.Helper()
redisRuntime := harness.StartRedisContainer(t)
userStub := harness.NewUserStub(t)
mailInternalAddr := harness.FreeTCPAddress(t)
authsessionPublicAddr := harness.FreeTCPAddress(t)
authsessionInternalAddr := harness.FreeTCPAddress(t)
mailBinary := harness.BuildBinary(t, "mail", "./mail/cmd/mail")
authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession")
if opts.mailSMTPMode == "" {
opts.mailSMTPMode = "stub"
}
mailEnv := map[string]string{
"MAIL_LOG_LEVEL": "info",
"MAIL_INTERNAL_HTTP_ADDR": mailInternalAddr,
"MAIL_REDIS_ADDR": redisRuntime.Addr,
"MAIL_TEMPLATE_DIR": moduleTemplateDir(t),
"MAIL_STREAM_BLOCK_TIMEOUT": "100ms",
"MAIL_OPERATOR_REQUEST_TIMEOUT": time.Second.String(),
"MAIL_SHUTDOWN_TIMEOUT": "2s",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
}
var smtpCapture *harness.SMTPCapture
switch opts.mailSMTPMode {
case "stub":
mailEnv["MAIL_SMTP_MODE"] = "stub"
case "smtp":
smtpCapture = harness.StartSMTPCapture(t, harness.SMTPCaptureConfig{
SupportsSTARTTLS: true,
})
mailEnv["MAIL_SMTP_MODE"] = "smtp"
mailEnv["MAIL_SMTP_ADDR"] = smtpCapture.Addr()
mailEnv["MAIL_SMTP_FROM_EMAIL"] = "noreply@example.com"
mailEnv["MAIL_SMTP_FROM_NAME"] = "Galaxy Mail"
mailEnv["MAIL_SMTP_TIMEOUT"] = "2s"
mailEnv["MAIL_SMTP_INSECURE_SKIP_VERIFY"] = "true"
mailEnv["SSL_CERT_FILE"] = smtpCapture.RootCAPath()
default:
t.Fatalf("unsupported mail SMTP mode %q", opts.mailSMTPMode)
}
mailProcess := harness.StartProcess(t, "mail", mailBinary, mailEnv)
waitForMailReady(t, mailProcess, "http://"+mailInternalAddr)
authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, map[string]string{
"AUTHSESSION_LOG_LEVEL": "info",
"AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr,
"AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr,
"AUTHSESSION_REDIS_ADDR": redisRuntime.Addr,
"AUTHSESSION_USER_SERVICE_MODE": "rest",
"AUTHSESSION_USER_SERVICE_BASE_URL": userStub.BaseURL(),
"AUTHSESSION_MAIL_SERVICE_MODE": "rest",
"AUTHSESSION_MAIL_SERVICE_BASE_URL": "http://" + mailInternalAddr,
"AUTHSESSION_MAIL_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr)
return &authsessionMailHarness{
userStub: userStub,
smtp: smtpCapture,
authsessionPublicURL: "http://" + authsessionPublicAddr,
mailInternalURL: "http://" + mailInternalAddr,
authsessionProcess: authsessionProcess,
mailProcess: mailProcess,
}
}
func (h *authsessionMailHarness) stopMail(t *testing.T) {
t.Helper()
h.mailProcess.Stop(t)
}
func (h *authsessionMailHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) sendEmailCodeResponse {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.authsessionPublicURL+authSendEmailCodePath,
map[string]string{"email": email},
map[string]string{"Accept-Language": acceptLanguage},
)
require.Equal(t, http.StatusOK, response.StatusCode, response.Body)
var body sendEmailCodeResponse
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
require.NotEmpty(t, body.ChallengeID)
return body
}
func (h *authsessionMailHarness) eventuallyListDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse {
t.Helper()
var response mailDeliveryListResponse
require.Eventually(t, func() bool {
response = h.listDeliveries(t, query)
return len(response.Items) > 0
}, 10*time.Second, 50*time.Millisecond)
return response
}
func (h *authsessionMailHarness) listDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse {
t.Helper()
target := h.mailInternalURL + mailDeliveriesPath
if encoded := query.Encode(); encoded != "" {
target += "?" + encoded
}
request, err := http.NewRequest(http.MethodGet, target, nil)
require.NoError(t, err)
return doJSONRequest[mailDeliveryListResponse](t, request, http.StatusOK)
}
func (h *authsessionMailHarness) getDelivery(t *testing.T, deliveryID string) mailDeliveryDetailResponse {
t.Helper()
request, err := http.NewRequest(http.MethodGet, h.mailInternalURL+mailDeliveriesPath+"/"+url.PathEscape(deliveryID), nil)
require.NoError(t, err)
return doJSONRequest[mailDeliveryDetailResponse](t, request, http.StatusOK)
}
func (h *authsessionMailHarness) getDeliveryAttempts(t *testing.T, deliveryID string) mailDeliveryAttemptsResponse {
t.Helper()
request, err := http.NewRequest(http.MethodGet, h.mailInternalURL+mailDeliveriesPath+"/"+url.PathEscape(deliveryID)+"/attempts", nil)
require.NoError(t, err)
return doJSONRequest[mailDeliveryAttemptsResponse](t, request, http.StatusOK)
}
func postJSONValueWithHeaders(t *testing.T, targetURL string, body any, headers map[string]string) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
if value == "" {
continue
}
request.Header.Set(key, value)
}
return doRequest(t, request)
}
func doJSONRequest[T any](t *testing.T, request *http.Request, wantStatus int) T {
t.Helper()
response := doRequest(t, request)
require.Equal(t, wantStatus, response.StatusCode, response.Body)
var decoded T
require.NoError(t, json.Unmarshal([]byte(response.Body), &decoded), response.Body)
return decoded
}
func doRequest(t *testing.T, request *http.Request) httpResponse {
t.Helper()
client := &http.Client{
Timeout: 500 * time.Millisecond,
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(),
}
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
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) {
request, err := http.NewRequest(http.MethodGet, baseURL+mailDeliveriesPath, nil)
require.NoError(t, err)
response, err := client.Do(request)
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())
}
func waitForAuthsessionPublicReady(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) {
response, err := postJSONValueMaybe(client, baseURL+authSendEmailCodePath, map[string]string{
"email": "",
})
if err == nil && response.StatusCode == http.StatusBadRequest {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("wait for authsession public readiness: timeout\n%s", process.Logs())
}
func postJSONValueMaybe(client *http.Client, targetURL string, body any) (httpResponse, error) {
payload, err := json.Marshal(body)
if err != nil {
return httpResponse{}, err
}
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return httpResponse{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return httpResponse{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return httpResponse{}, err
}
return httpResponse{
StatusCode: response.StatusCode,
Body: string(responseBody),
Header: response.Header.Clone(),
}, nil
}
func moduleTemplateDir(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), "..", ".."))
}